dsh-context-compression-improved 0.3.0 → 0.4.0-beta.1
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/.githooks/pre-push +37 -0
- package/package.json +3 -2
- package/packages/selector/cordis.patch.yml +12 -5
- package/packages/selector/lib/client.d.ts +24 -0
- package/packages/selector/lib/client.js +506 -5
- package/packages/selector/lib/config.js +27 -4
- package/packages/selector/lib/index.d.ts +7 -0
- package/packages/selector/lib/index.js +229 -1
- package/packages/selector/lib/pruner.d.ts +254 -0
- package/packages/selector/lib/pruner.js +714 -25
- package/packages/selector/package.json +0 -1
- package/packages/selector/src/client/EstimatorControls.tsx +101 -0
- package/packages/selector/src/client/ReviewOverlay.tsx +320 -0
- package/packages/selector/src/client/index.ts +17 -0
- package/packages/selector/src/client/locales.ts +38 -0
- package/packages/selector/src/client/preset-options.ts +1 -0
- package/packages/selector/src/client/review-scope.ts +16 -0
- package/packages/selector/src/client/settings-section.tsx +17 -8
- package/packages/selector/src/index.ts +308 -0
- package/packages/selector/src/profiles.ts +28 -1
- package/packages/selector/src/pruner/state.ts +27 -0
- package/packages/selector/src/pruner.ts +430 -10
- package/packages/selector/src/runtime/audit.ts +27 -0
- package/packages/selector/src/runtime/config.ts +33 -1
- package/packages/selector/src/runtime/tokenpilot/estimator.ts +60 -13
- package/packages/selector/src/runtime/tokenpilot/proposal.ts +223 -0
- package/packages/selector/src/runtime/tokenpilot/review-queue.ts +231 -0
- package/packages/selector/src/runtime/tokenpilot/review-storage.ts +122 -0
- package/packages/selector/src/runtime/types.ts +17 -0
- package/packages/selector/tests/code-skeleton.client.spec.ts +3 -2
- package/packages/selector/tests/custom-contract.client.spec.ts +3 -2
- package/packages/selector/tests/preset-options-write.client.spec.ts +34 -1
- package/packages/selector/tests/review-overlay.client.spec.tsx +118 -0
- package/packages/selector/tests/review-routes.host.spec.ts +290 -0
- package/packages/selector/tests/runtime/audit.spec.ts +44 -0
- package/packages/selector/tests/runtime/tokenpilot/estimator.spec.ts +23 -0
- package/packages/selector/tests/runtime/tokenpilot/profile-baseline.spec.ts +5 -0
- package/packages/selector/tests/runtime/tokenpilot/proposal.spec.ts +199 -0
- package/packages/selector/tests/runtime/tokenpilot/pruner-review.spec.ts +313 -0
- package/packages/selector/tests/runtime/tokenpilot/review-queue.spec.ts +168 -0
- package/packages/selector/tests/settings-seat.client.spec.ts +5 -4
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
tailTrimStub,
|
|
34
34
|
} from './runtime/tail-trim.ts'
|
|
35
35
|
import { installContextCompressionRetrieve } from './runtime/retrieve.ts'
|
|
36
|
-
import type { PrunerState } from './pruner/state.ts'
|
|
36
|
+
import type { PrunerState, ReviewSessionSummary } from './pruner/state.ts'
|
|
37
37
|
import { countOmittedLines, CAPACITY_PRESSURE_RATIO } from './pruner/tuning.ts'
|
|
38
38
|
import type { ToolCallInfo, SnapshotCandidate, PlannedReplacement, HistoryPlanOutcome } from './pruner/types.ts'
|
|
39
39
|
import {
|
|
@@ -67,10 +67,22 @@ import {
|
|
|
67
67
|
buildEstimatorSystemPrompt,
|
|
68
68
|
buildEstimatorUserPrompt,
|
|
69
69
|
isCoolingDown,
|
|
70
|
-
|
|
70
|
+
parseEstimatorAnswerDetailed,
|
|
71
71
|
type EstimatorFailures,
|
|
72
72
|
type EstimatorSample,
|
|
73
73
|
} from './runtime/tokenpilot/estimator.ts'
|
|
74
|
+
import {
|
|
75
|
+
classifyCandidates,
|
|
76
|
+
contentDigest,
|
|
77
|
+
} from './runtime/tokenpilot/proposal.ts'
|
|
78
|
+
import {
|
|
79
|
+
MemoryReviewStore,
|
|
80
|
+
ReviewQueue,
|
|
81
|
+
type ReviewProposalRecord,
|
|
82
|
+
type ReviewQueueStore,
|
|
83
|
+
type ReviewReceipt,
|
|
84
|
+
} from './runtime/tokenpilot/review-queue.ts'
|
|
85
|
+
import { openReviewStorage } from './runtime/tokenpilot/review-storage.ts'
|
|
74
86
|
|
|
75
87
|
import {
|
|
76
88
|
DedupeTable,
|
|
@@ -222,7 +234,23 @@ export class ToolResultPruner extends Service {
|
|
|
222
234
|
activeRequestBoundaries: new WeakMap(),
|
|
223
235
|
tailTrimBoundaryAttempts: new WeakMap(),
|
|
224
236
|
policyResolutionAudits: new WeakMap(),
|
|
225
|
-
|
|
237
|
+
reviewStore: new MemoryReviewStore(),
|
|
238
|
+
reviewQueues: new WeakMap(),
|
|
239
|
+
reviewClocks: new WeakMap(),
|
|
240
|
+
estimatorRemainingTurns: new WeakMap(),
|
|
241
|
+
reviewSummaries: new WeakMap(),
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// TokenPilot-inspired R4: upgrade the review queue to durable storage when
|
|
245
|
+
// the optional storageDomain seam is available; the memory fallback above
|
|
246
|
+
// serves every session until (and unless) that open succeeds.
|
|
247
|
+
void openReviewStorage(name => this.ctx.get(name as never))
|
|
248
|
+
.then(store => {
|
|
249
|
+
if (store !== undefined) this.state.reviewStore = store
|
|
250
|
+
})
|
|
251
|
+
.catch(() => {
|
|
252
|
+
this.ctx.logger.warn('context-compression review storage unavailable; keeping in-memory review queue')
|
|
253
|
+
})
|
|
226
254
|
|
|
227
255
|
ctx.on('session/event', (session, event) => {
|
|
228
256
|
this.scanForSeededNativeSummary(session)
|
|
@@ -255,6 +283,7 @@ export class ToolResultPruner extends Service {
|
|
|
255
283
|
// Only the immediately preceding step can contain results that have
|
|
256
284
|
// not yet been exposed. This freezes both REDUCE and KEEP decisions:
|
|
257
285
|
// older original events are never reconsidered after a profile change.
|
|
286
|
+
this.reviewClock(agent.session, turn)
|
|
258
287
|
this.runRequestBoundary(agent.session, turn, step - 1, signal)
|
|
259
288
|
} catch (error: unknown) {
|
|
260
289
|
this.auditFailure(agent.session, 'fresh', 'request-boundary', error)
|
|
@@ -283,6 +312,16 @@ export class ToolResultPruner extends Service {
|
|
|
283
312
|
this.auditFailure(agent.session, 'fresh', 'terminal-pass', error)
|
|
284
313
|
ctx.logger.warn('context-compression terminal pass failed open: %o', error)
|
|
285
314
|
}
|
|
315
|
+
// TokenPilot-inspired R4: review-pipeline housekeeping at the turn
|
|
316
|
+
// boundary, strictly fail-open — expire stale pendings, then execute
|
|
317
|
+
// every approved proposal as one merged batch. Order matters: expiring
|
|
318
|
+
// first keeps just-expired proposals from executing.
|
|
319
|
+
try {
|
|
320
|
+
this.expireReviewProposals(agent.session, turn)
|
|
321
|
+
this.applyApprovedProposals(agent.session)
|
|
322
|
+
} catch (error: unknown) {
|
|
323
|
+
ctx.logger.warn('context-compression review turn-boundary pass failed open: %o', error)
|
|
324
|
+
}
|
|
286
325
|
// TokenPilot-inspired E1: advisory estimator pass, strictly off the
|
|
287
326
|
// synchronous chain. Verdicts only feed the next pressure pass.
|
|
288
327
|
void this.postflightEstimatorPass(agent.session, signal).catch(() => undefined)
|
|
@@ -341,7 +380,7 @@ export class ToolResultPruner extends Service {
|
|
|
341
380
|
const planned = eligible
|
|
342
381
|
.map(candidate => this.planNative(candidate, session, stage, policy, view))
|
|
343
382
|
.filter((entry): entry is PlannedReplacement => entry !== null)
|
|
344
|
-
landed.push(...this.landAll(session, planned))
|
|
383
|
+
landed.push(...this.landAll(session, this.triageForReview(session, policy, planned)))
|
|
345
384
|
if (landed.length === 0) {
|
|
346
385
|
const exact = eligible.flatMap(candidate => candidate.count.kind === 'exact-tokenizer'
|
|
347
386
|
? [candidate.count.tokens] : [])
|
|
@@ -377,7 +416,7 @@ export class ToolResultPruner extends Service {
|
|
|
377
416
|
capacityPressure,
|
|
378
417
|
)
|
|
379
418
|
if (historyAllowed) {
|
|
380
|
-
landed.push(...this.landAll(session, historyOutcome.plans))
|
|
419
|
+
landed.push(...this.landAll(session, this.triageForReview(session, policy, historyOutcome.plans)))
|
|
381
420
|
}
|
|
382
421
|
}
|
|
383
422
|
} else {
|
|
@@ -385,7 +424,7 @@ export class ToolResultPruner extends Service {
|
|
|
385
424
|
if (historyAllowed) {
|
|
386
425
|
historyOutcome = this.planHistoricalAging(session, policy, view)
|
|
387
426
|
if (historyOutcome.kind === 'planned') {
|
|
388
|
-
landed.push(...this.landAll(session, historyOutcome.plans))
|
|
427
|
+
landed.push(...this.landAll(session, this.triageForReview(session, policy, historyOutcome.plans)))
|
|
389
428
|
}
|
|
390
429
|
}
|
|
391
430
|
}
|
|
@@ -569,7 +608,13 @@ export class ToolResultPruner extends Service {
|
|
|
569
608
|
verdicts = new Map()
|
|
570
609
|
this.state.estimatorVerdicts.set(session, verdicts)
|
|
571
610
|
}
|
|
572
|
-
|
|
611
|
+
const detailed = parseEstimatorAnswerDetailed(answer)
|
|
612
|
+
// TokenPilot-inspired R4: the optional session-level Ŝ rides on the same
|
|
613
|
+
// answer; it only sharpens the benefit model and is never required.
|
|
614
|
+
if (detailed.expectedRemainingTurns !== undefined) {
|
|
615
|
+
this.state.estimatorRemainingTurns.set(session, detailed.expectedRemainingTurns)
|
|
616
|
+
}
|
|
617
|
+
for (const verdict of detailed.verdicts) {
|
|
573
618
|
if (verdicts.has(verdict.seq)) continue
|
|
574
619
|
verdicts.set(verdict.seq, verdict.expired)
|
|
575
620
|
if (verdict.expired) expired += 1
|
|
@@ -594,6 +639,367 @@ export class ToolResultPruner extends Service {
|
|
|
594
639
|
})
|
|
595
640
|
}
|
|
596
641
|
|
|
642
|
+
// ─────────── TokenPilot-inspired R4: human-gated review pipeline ───────────
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* The per-session review queue, or `undefined` while review mode is off
|
|
646
|
+
* (every review path must then behave exactly like before).
|
|
647
|
+
*/
|
|
648
|
+
private reviewQueueFor(session: Session, policy: CompressionPolicy | undefined): ReviewQueue | undefined {
|
|
649
|
+
const presetOptions = policy?.presetOptions
|
|
650
|
+
if (presetOptions?.reviewMode !== true) return undefined
|
|
651
|
+
let queue = this.state.reviewQueues.get(session)
|
|
652
|
+
if (queue === undefined) {
|
|
653
|
+
queue = new ReviewQueue(this.state.reviewStore, { timeoutTurns: presetOptions.reviewTimeoutTurns })
|
|
654
|
+
this.state.reviewQueues.set(session, queue)
|
|
655
|
+
}
|
|
656
|
+
return queue
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Monotonic per-session turn clock for review patience and expiry. Bumped by
|
|
661
|
+
* the agent loop payloads (`pre-step` / `turn-stopping`); passes without a
|
|
662
|
+
* turn coordinate reuse the last observed value.
|
|
663
|
+
*/
|
|
664
|
+
private reviewClock(session: Session, turn?: number): number {
|
|
665
|
+
const previous = this.state.reviewClocks.get(session) ?? 0
|
|
666
|
+
const next = typeof turn === 'number' && Number.isSafeInteger(turn) && turn > previous ? turn : previous
|
|
667
|
+
this.state.reviewClocks.set(session, next)
|
|
668
|
+
return next
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
private auditReviewOutcome(
|
|
672
|
+
session: Session,
|
|
673
|
+
proposal: Pick<ReviewProposalRecord, 'id' | 'kind' | 'items'>,
|
|
674
|
+
event: 'enqueue' | 'expire' | 'decide' | 'apply-void' | 'apply-receipt',
|
|
675
|
+
extra: {
|
|
676
|
+
decision?: 'approved' | 'rejected' | 'ignored' | undefined
|
|
677
|
+
receiptStatus?: 'applied' | 'deferred' | undefined
|
|
678
|
+
reasonCode?: string | undefined
|
|
679
|
+
} = {},
|
|
680
|
+
turnIndex?: number,
|
|
681
|
+
): void {
|
|
682
|
+
const tokensBefore = proposal.items.reduce((sum, item) => sum + item.tokensBefore, 0)
|
|
683
|
+
const tokensAfter = proposal.items.reduce((sum, item) => sum + item.tokensAfter, 0)
|
|
684
|
+
emitCompressionAudit(this.ctx.logger, {
|
|
685
|
+
schemaVersion: 1,
|
|
686
|
+
kind: 'review-outcome',
|
|
687
|
+
sessionId: String(session.id),
|
|
688
|
+
proposalId: proposal.id,
|
|
689
|
+
proposalKind: proposal.kind,
|
|
690
|
+
event,
|
|
691
|
+
...extra.decision === undefined ? {} : { decision: extra.decision },
|
|
692
|
+
...extra.receiptStatus === undefined ? {} : { receiptStatus: extra.receiptStatus },
|
|
693
|
+
...extra.reasonCode === undefined ? {} : { reasonCode: extra.reasonCode },
|
|
694
|
+
itemSeqs: proposal.items.map(item => item.seq),
|
|
695
|
+
tokensBefore,
|
|
696
|
+
tokensAfter,
|
|
697
|
+
...turnIndex === undefined ? {} : { turnIndex },
|
|
698
|
+
})
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Review-mode triage hook: classify one pass's planned replacements and
|
|
703
|
+
* withhold the review bucket from landing, enqueuing it for human approval
|
|
704
|
+
* instead. With review mode off (or nothing planned) this is the identity.
|
|
705
|
+
*
|
|
706
|
+
* The digest freezes each candidate's ORIGINAL surface content, so the apply
|
|
707
|
+
* point can prove "what is removed now is what was approved then".
|
|
708
|
+
*/
|
|
709
|
+
private triageForReview(
|
|
710
|
+
session: Session,
|
|
711
|
+
policy: CompressionPolicy,
|
|
712
|
+
plans: readonly PlannedReplacement[],
|
|
713
|
+
): readonly PlannedReplacement[] {
|
|
714
|
+
const queue = this.reviewQueueFor(session, policy)
|
|
715
|
+
if (queue === undefined || plans.length === 0) return plans
|
|
716
|
+
const presetOptions = policy.presetOptions!
|
|
717
|
+
const estimatorVerdicts = this.state.estimatorVerdicts.get(session)
|
|
718
|
+
const estimatorSeqs = new Set<number>([...(estimatorVerdicts?.entries() ?? [])]
|
|
719
|
+
.filter(([, expired]) => expired)
|
|
720
|
+
.map(([seq]) => seq))
|
|
721
|
+
const input = {
|
|
722
|
+
alpha: presetOptions.cacheHitDiscountAlpha,
|
|
723
|
+
// One-phase approximation of the tail that a mutation must refill: the
|
|
724
|
+
// frozen protected recent-token tail (findings.md, 约束与依赖).
|
|
725
|
+
tailTokens: Math.max(1, policy.historyKeepRecentTokens),
|
|
726
|
+
reviewHighImpactTokens: presetOptions.reviewHighImpactTokens,
|
|
727
|
+
...this.state.estimatorRemainingTurns.get(session) === undefined
|
|
728
|
+
? {}
|
|
729
|
+
: { remainingTurns: this.state.estimatorRemainingTurns.get(session) },
|
|
730
|
+
estimatorSeqs,
|
|
731
|
+
}
|
|
732
|
+
const classified = classifyCandidates(plans.map(plan => ({
|
|
733
|
+
sourceSeq: plan.sourceSeq,
|
|
734
|
+
tokensBefore: plan.tokensBefore,
|
|
735
|
+
tokensAfter: plan.tokensAfter,
|
|
736
|
+
component: plan.component,
|
|
737
|
+
reducer: plan.reducer,
|
|
738
|
+
content: plan.candidate.event.data.message.content[0].content,
|
|
739
|
+
})), input)
|
|
740
|
+
const autoSeqs = new Set(classified.auto.map(candidate => candidate.sourceSeq))
|
|
741
|
+
const clock = this.reviewClock(session)
|
|
742
|
+
for (const skeleton of classified.review) {
|
|
743
|
+
const enqueued = queue.enqueue(String(session.id), skeleton, clock)
|
|
744
|
+
if (enqueued) {
|
|
745
|
+
this.auditReviewOutcome(session, {
|
|
746
|
+
id: skeleton.id,
|
|
747
|
+
kind: skeleton.kind,
|
|
748
|
+
items: skeleton.items,
|
|
749
|
+
}, 'enqueue', {}, clock)
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
return plans.filter(plan => autoSeqs.has(plan.sourceSeq))
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Execute every approved proposal of one session as ONE merged replacement
|
|
757
|
+
* batch at the current turn boundary, following the upstream applied-receipt
|
|
758
|
+
* discipline: applied receipts are built only from real mutation evidence —
|
|
759
|
+
* estimates never cross into applied savings.
|
|
760
|
+
*
|
|
761
|
+
* Per proposal: every item's frozen digest is re-checked against the current
|
|
762
|
+
* surface content; any mismatch voids the whole proposal (deferred with a
|
|
763
|
+
* reason code) instead of deleting something the user never approved.
|
|
764
|
+
* Fail-open: any unexpected error only logs and leaves the queue intact.
|
|
765
|
+
*/
|
|
766
|
+
applyApprovedProposals(session: Session): void {
|
|
767
|
+
const policy = this.activePolicy(session)
|
|
768
|
+
const queue = this.reviewQueueFor(session, policy)
|
|
769
|
+
if (queue === undefined) return
|
|
770
|
+
const sessionId = String(session.id)
|
|
771
|
+
const approved = [...queue.listApproved(sessionId)]
|
|
772
|
+
if (approved.length === 0) return
|
|
773
|
+
try {
|
|
774
|
+
const view = measureForCompaction(this.ctx, session)
|
|
775
|
+
const candidatesBySeq = new Map(this.snapshot(session, view).map(candidate => [candidate.seq, candidate]))
|
|
776
|
+
const plans: PlannedReplacement[] = []
|
|
777
|
+
const settled: {
|
|
778
|
+
proposal: ReviewProposalRecord
|
|
779
|
+
auditItems?: ReviewProposalRecord['items']
|
|
780
|
+
receipt: ReviewReceipt
|
|
781
|
+
}[] = []
|
|
782
|
+
const now = new Date().toISOString()
|
|
783
|
+
for (const proposal of approved) {
|
|
784
|
+
// Digest re-check at the execution point (the approval point cannot
|
|
785
|
+
// protect against later mutations of the same seq).
|
|
786
|
+
const voidedReason = this.reviewProposalVoided(session, proposal)
|
|
787
|
+
if (voidedReason !== undefined) {
|
|
788
|
+
settled.push({
|
|
789
|
+
proposal,
|
|
790
|
+
receipt: {
|
|
791
|
+
status: 'deferred',
|
|
792
|
+
reasonCode: voidedReason,
|
|
793
|
+
estimatedTokens: proposal.benefit.recoveredTokens,
|
|
794
|
+
updatedAt: now,
|
|
795
|
+
},
|
|
796
|
+
})
|
|
797
|
+
continue
|
|
798
|
+
}
|
|
799
|
+
// One merged batch: every still-valid proposal takes the same
|
|
800
|
+
// whole-result placeholder path in a single landAll call, so the tail
|
|
801
|
+
// refill penalty is paid once for the whole approval set.
|
|
802
|
+
const batchPlans: PlannedReplacement[] = []
|
|
803
|
+
let planned = true
|
|
804
|
+
for (const item of proposal.items) {
|
|
805
|
+
const candidate = candidatesBySeq.get(item.seq)
|
|
806
|
+
if (candidate === undefined) {
|
|
807
|
+
planned = false
|
|
808
|
+
break
|
|
809
|
+
}
|
|
810
|
+
const plan = this.planAggregate(
|
|
811
|
+
candidate,
|
|
812
|
+
session,
|
|
813
|
+
view,
|
|
814
|
+
'review-approved-whole-result',
|
|
815
|
+
'pressure',
|
|
816
|
+
undefined,
|
|
817
|
+
'history',
|
|
818
|
+
policy?.historyMode,
|
|
819
|
+
)
|
|
820
|
+
if (plan === null) {
|
|
821
|
+
planned = false
|
|
822
|
+
break
|
|
823
|
+
}
|
|
824
|
+
batchPlans.push(plan)
|
|
825
|
+
}
|
|
826
|
+
if (!planned || batchPlans.length === 0) {
|
|
827
|
+
settled.push({
|
|
828
|
+
proposal,
|
|
829
|
+
receipt: {
|
|
830
|
+
status: 'deferred',
|
|
831
|
+
reasonCode: 'review_receipt_execution_invalid',
|
|
832
|
+
estimatedTokens: proposal.benefit.recoveredTokens,
|
|
833
|
+
updatedAt: now,
|
|
834
|
+
},
|
|
835
|
+
})
|
|
836
|
+
continue
|
|
837
|
+
}
|
|
838
|
+
const landed = this.landAll(session, batchPlans)
|
|
839
|
+
if (landed.length === 0) {
|
|
840
|
+
settled.push({
|
|
841
|
+
proposal,
|
|
842
|
+
receipt: {
|
|
843
|
+
status: 'deferred',
|
|
844
|
+
reasonCode: 'review_receipt_execution_invalid',
|
|
845
|
+
estimatedTokens: proposal.benefit.recoveredTokens,
|
|
846
|
+
updatedAt: now,
|
|
847
|
+
},
|
|
848
|
+
})
|
|
849
|
+
continue
|
|
850
|
+
}
|
|
851
|
+
const landedForProposal = new Map(landed.map(entry => [entry.originalSeq, entry]))
|
|
852
|
+
const measuredItems = proposal.items.map((item) => {
|
|
853
|
+
const entry = landedForProposal.get(item.seq)
|
|
854
|
+
return entry === undefined ? item : {
|
|
855
|
+
...item,
|
|
856
|
+
tokensBefore: entry.tokensBefore,
|
|
857
|
+
tokensAfter: entry.tokensAfter,
|
|
858
|
+
}
|
|
859
|
+
})
|
|
860
|
+
const appliedTokens = measuredItems.reduce(
|
|
861
|
+
(sum, item) => sum + item.tokensBefore - item.tokensAfter,
|
|
862
|
+
0,
|
|
863
|
+
)
|
|
864
|
+
settled.push({
|
|
865
|
+
proposal,
|
|
866
|
+
// The audit face carries the MEASURED numbers of the executed
|
|
867
|
+
// mutation; estimates never cross into applied savings.
|
|
868
|
+
auditItems: measuredItems,
|
|
869
|
+
receipt: {
|
|
870
|
+
status: 'applied',
|
|
871
|
+
estimatedTokens: proposal.benefit.recoveredTokens,
|
|
872
|
+
appliedTokens,
|
|
873
|
+
updatedAt: now,
|
|
874
|
+
},
|
|
875
|
+
})
|
|
876
|
+
}
|
|
877
|
+
for (const { proposal, auditItems, receipt } of settled) {
|
|
878
|
+
queue.recordReceipt(sessionId, proposal.id, receipt)
|
|
879
|
+
const summary = this.reviewSummaryFor(session)
|
|
880
|
+
if (receipt.status === 'applied') summary.reviewApplied += 1
|
|
881
|
+
else summary.voided += 1
|
|
882
|
+
this.auditReviewOutcome(
|
|
883
|
+
session,
|
|
884
|
+
auditItems === undefined
|
|
885
|
+
? proposal
|
|
886
|
+
: { ...proposal, items: auditItems },
|
|
887
|
+
receipt.status === 'applied' ? 'apply-receipt' : 'apply-void',
|
|
888
|
+
receipt.status === 'applied'
|
|
889
|
+
? { receiptStatus: 'applied' }
|
|
890
|
+
: { receiptStatus: 'deferred', reasonCode: receipt.reasonCode },
|
|
891
|
+
)
|
|
892
|
+
}
|
|
893
|
+
} catch (error: unknown) {
|
|
894
|
+
// Fail-open: a broken apply must never break the turn or the queue.
|
|
895
|
+
this.ctx.logger.warn('context-compression review apply failed open: %o', error)
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* Current surface content at one seq: the newest covering replacement's
|
|
901
|
+
* blocks when the seq was rewritten, otherwise the original event's blocks.
|
|
902
|
+
*/
|
|
903
|
+
private surfaceContentAt(session: Session, seq: number): ContentBlock[] | undefined {
|
|
904
|
+
let content: ContentBlock[] | undefined
|
|
905
|
+
for (const event of sessionEvents(session)) {
|
|
906
|
+
if (event.type !== 'tool/result') continue
|
|
907
|
+
const op = event.surfaceOp
|
|
908
|
+
if (typeof op === 'object' && op.op === 'replace' && op.startSeq <= seq && seq <= op.endSeq) {
|
|
909
|
+
content = event.data.message.content[0].content
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
if (content !== undefined) return content
|
|
913
|
+
const original = sessionEvents(session).find(entry => entry.seq === seq)
|
|
914
|
+
return original?.type === 'tool/result' ? original.data.message.content[0].content : undefined
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* The execution-point digest check: `undefined` when every item's frozen
|
|
919
|
+
* digest still matches the current surface content, otherwise the aligned
|
|
920
|
+
* reason code explaining the void.
|
|
921
|
+
*/
|
|
922
|
+
private reviewProposalVoided(
|
|
923
|
+
session: Session,
|
|
924
|
+
proposal: ReviewProposalRecord,
|
|
925
|
+
): 'review_receipt_digest_invalid' | 'review_receipt_missing_candidate' | undefined {
|
|
926
|
+
for (const item of proposal.items) {
|
|
927
|
+
const current = this.surfaceContentAt(session, item.seq)
|
|
928
|
+
if (current === undefined) return 'review_receipt_missing_candidate'
|
|
929
|
+
if (contentDigest(current) !== item.digest) return 'review_receipt_digest_invalid'
|
|
930
|
+
}
|
|
931
|
+
return undefined
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
private reviewSummaryFor(session: Session): ReviewSessionSummary {
|
|
935
|
+
let summary = this.state.reviewSummaries.get(session)
|
|
936
|
+
if (summary === undefined) {
|
|
937
|
+
summary = { autoApplied: 0, reviewApplied: 0, expired: 0, voided: 0 }
|
|
938
|
+
this.state.reviewSummaries.set(session, summary)
|
|
939
|
+
}
|
|
940
|
+
return summary
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/** Live pending review proposals of one session; empty when review mode is off. */
|
|
944
|
+
listReviewProposals(session: Session): readonly ReviewProposalRecord[] {
|
|
945
|
+
const queue = this.reviewQueueFor(session, this.activePolicy(session))
|
|
946
|
+
return queue?.listPending(String(session.id)) ?? []
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* Every session's live pending proposals, for the floating window's
|
|
951
|
+
* aggregate badge (the client carries no session id of its own).
|
|
952
|
+
*/
|
|
953
|
+
listAllReviewProposals(): readonly { readonly sessionId: string, readonly proposals: readonly ReviewProposalRecord[] }[] {
|
|
954
|
+
const ids = this.state.reviewStore.ids?.() ?? []
|
|
955
|
+
const reader = new ReviewQueue(this.state.reviewStore, { timeoutTurns: 1 })
|
|
956
|
+
return ids
|
|
957
|
+
.map(sessionId => ({ sessionId, proposals: [...reader.listPending(sessionId)] }))
|
|
958
|
+
.filter(entry => entry.proposals.length > 0)
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/** Four-state outcome counters of one session (floating-window summary row). */
|
|
962
|
+
reviewSummary(session: Session): ReviewSessionSummary {
|
|
963
|
+
return { ...this.reviewSummaryFor(session) }
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* Record one human decision. Returns the outcome, or `undefined` when
|
|
968
|
+
* review mode is off for this session (the route maps that to 503).
|
|
969
|
+
*/
|
|
970
|
+
decideReviewProposal(
|
|
971
|
+
session: Session,
|
|
972
|
+
proposalId: string,
|
|
973
|
+
decision: 'approved' | 'rejected' | 'ignored',
|
|
974
|
+
): { ok: true } | { ok: false, reason: 'unknown-proposal' | 'not-pending' } | undefined {
|
|
975
|
+
const queue = this.reviewQueueFor(session, this.activePolicy(session))
|
|
976
|
+
if (queue === undefined) return undefined
|
|
977
|
+
const sessionId = String(session.id)
|
|
978
|
+
const pending = queue.listPending(sessionId).find(entry => entry.id === proposalId)
|
|
979
|
+
const outcome = queue.decide(sessionId, proposalId, decision)
|
|
980
|
+
if (outcome.ok && pending !== undefined) {
|
|
981
|
+
this.auditReviewOutcome(session, pending, 'decide', { decision }, this.reviewClock(session))
|
|
982
|
+
}
|
|
983
|
+
return outcome
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* Expire stale pending proposals at one turn boundary and audit each.
|
|
988
|
+
* Public because tests drive it directly; the turn-stopping handler calls
|
|
989
|
+
* it with the loop's own turn index.
|
|
990
|
+
*/
|
|
991
|
+
expireReviewProposals(session: Session, turnIndex?: number): readonly ReviewProposalRecord[] {
|
|
992
|
+
const queue = this.reviewQueueFor(session, this.activePolicy(session))
|
|
993
|
+
if (queue === undefined) return []
|
|
994
|
+
const clock = this.reviewClock(session, turnIndex)
|
|
995
|
+
const expired = queue.expireTurn(String(session.id), clock)
|
|
996
|
+
if (expired.length > 0) this.reviewSummaryFor(session).expired += expired.length
|
|
997
|
+
for (const proposal of expired) {
|
|
998
|
+
this.auditReviewOutcome(session, proposal, 'expire', {}, clock)
|
|
999
|
+
}
|
|
1000
|
+
return expired
|
|
1001
|
+
}
|
|
1002
|
+
|
|
597
1003
|
private activePolicy(
|
|
598
1004
|
session: Session,
|
|
599
1005
|
contextWindowTokens?: number,
|
|
@@ -601,8 +1007,15 @@ export class ToolResultPruner extends Service {
|
|
|
601
1007
|
): CompressionPolicy | undefined {
|
|
602
1008
|
const settings = this.activeSettings(session)
|
|
603
1009
|
try {
|
|
1010
|
+
// R4 bridge: the persisted settings document's presetOptions (the
|
|
1011
|
+
// settings-card writes, including reviewMode) must reach the policy —
|
|
1012
|
+
// before this bridge only the estimator endpoint read them directly and
|
|
1013
|
+
// every policy consumer saw the deployment defaults. User settings win
|
|
1014
|
+
// over deployment config; absent fields inherit via mergePresetOptions.
|
|
604
1015
|
const policy = resolvePolicy(
|
|
605
|
-
|
|
1016
|
+
settings.presetOptions === undefined
|
|
1017
|
+
? this.state.config
|
|
1018
|
+
: { ...this.state.config, presetOptions: settings.presetOptions },
|
|
606
1019
|
settings.profile,
|
|
607
1020
|
settings.custom,
|
|
608
1021
|
{
|
|
@@ -1053,9 +1466,10 @@ export class ToolResultPruner extends Service {
|
|
|
1053
1466
|
}
|
|
1054
1467
|
}
|
|
1055
1468
|
|
|
1056
|
-
const
|
|
1469
|
+
const freshCandidates = candidates
|
|
1057
1470
|
.map(candidate => plans.get(candidate.seq))
|
|
1058
|
-
.filter((plan): plan is PlannedReplacement => plan !== undefined)
|
|
1471
|
+
.filter((plan): plan is PlannedReplacement => plan !== undefined)
|
|
1472
|
+
const landed = this.landAll(session, this.triageForReview(session, policy, freshCandidates))
|
|
1059
1473
|
const freshLanded = landed.some(entry => entry.stage === 'fresh'
|
|
1060
1474
|
&& plans.get(entry.originalSeq)?.component === 'fresh')
|
|
1061
1475
|
const aggregateLanded = landed.some(entry => entry.stage === 'fresh'
|
|
@@ -1890,6 +2304,12 @@ export class ToolResultPruner extends Service {
|
|
|
1890
2304
|
tokenizerId: plan.tokenizerId,
|
|
1891
2305
|
tokenizerRevision: plan.tokenizerRevision,
|
|
1892
2306
|
})
|
|
2307
|
+
// R4: the four-state summary counts automatic-path rewrites at the single
|
|
2308
|
+
// landing chokepoint; the review-approved batch settles its own counters
|
|
2309
|
+
// in applyApprovedProposals.
|
|
2310
|
+
if (plan.reducer !== 'review-approved-whole-result') {
|
|
2311
|
+
this.reviewSummaryFor(session).autoApplied += 1
|
|
2312
|
+
}
|
|
1893
2313
|
return {
|
|
1894
2314
|
originalSeq: candidate.seq,
|
|
1895
2315
|
sourceSeq: plan.sourceSeq,
|
|
@@ -167,6 +167,32 @@ export interface EstimatorOutcomeAuditRecord extends CompressionAuditBase {
|
|
|
167
167
|
readonly ok: boolean
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
/** Lifecycle of one human-gated review proposal. Only numeric and enum fields — never content. */
|
|
171
|
+
export interface ReviewOutcomeAuditRecord extends CompressionAuditBase {
|
|
172
|
+
readonly kind: 'review-outcome'
|
|
173
|
+
/** Stable proposal id (sha-256 digest cut, 12 hex chars). */
|
|
174
|
+
readonly proposalId: string
|
|
175
|
+
/** Reduction kind the proposal came from. */
|
|
176
|
+
readonly proposalKind: 'estimator' | 'dedup' | 'read-state'
|
|
177
|
+
readonly event:
|
|
178
|
+
| 'enqueue'
|
|
179
|
+
| 'expire'
|
|
180
|
+
| 'decide'
|
|
181
|
+
| 'apply-void'
|
|
182
|
+
| 'apply-receipt'
|
|
183
|
+
/** Human decision (decide events only). */
|
|
184
|
+
readonly decision?: 'approved' | 'rejected' | 'ignored'
|
|
185
|
+
/** Execution receipt state (apply-receipt only). */
|
|
186
|
+
readonly receiptStatus?: 'applied' | 'deferred'
|
|
187
|
+
/** Aligned reason code (deferred receipts and void applications only). */
|
|
188
|
+
readonly reasonCode?: string
|
|
189
|
+
readonly itemSeqs: readonly number[]
|
|
190
|
+
readonly tokensBefore: number
|
|
191
|
+
readonly tokensAfter: number
|
|
192
|
+
/** Turn index the event happened at. */
|
|
193
|
+
readonly turnIndex?: number
|
|
194
|
+
}
|
|
195
|
+
|
|
170
196
|
/** Closed version-one context-compression audit vocabulary. */
|
|
171
197
|
export type CompressionAuditRecord =
|
|
172
198
|
| CompressionPolicyFrozenAuditRecord
|
|
@@ -177,6 +203,7 @@ export type CompressionAuditRecord =
|
|
|
177
203
|
| NativeAutoCompactAuditRecord
|
|
178
204
|
| SummaryLocatorAuditRecord
|
|
179
205
|
| EstimatorOutcomeAuditRecord
|
|
206
|
+
| ReviewOutcomeAuditRecord
|
|
180
207
|
|
|
181
208
|
/** Minimal logger method consumed by the audit publisher. */
|
|
182
209
|
export interface CompressionAuditLogger {
|
|
@@ -127,12 +127,13 @@ export function parsePresetOptionsSettings(value: unknown): PresetOptionsSetting
|
|
|
127
127
|
const allowed = new Set([
|
|
128
128
|
'dedupeToolResults', 'summaryLocator', 'prefixStabilizer', 'readState', 'estimatorMode',
|
|
129
129
|
'estimatorProvider', 'estimatorModel', 'estimatorBaseUrl', 'estimatorApiKey', 'estimatorTimeoutMs',
|
|
130
|
+
'reviewMode', 'reviewTimeoutTurns', 'cacheHitDiscountAlpha', 'reviewHighImpactTokens',
|
|
130
131
|
])
|
|
131
132
|
const unknown = Object.keys(value).find(key => !allowed.has(key))
|
|
132
133
|
if (unknown !== undefined) {
|
|
133
134
|
throw new TypeError(`Context-compression presetOptions: unknown key "${unknown}"`)
|
|
134
135
|
}
|
|
135
|
-
const booleans = ['dedupeToolResults', 'summaryLocator', 'prefixStabilizer', 'readState'] as const
|
|
136
|
+
const booleans = ['dedupeToolResults', 'summaryLocator', 'prefixStabilizer', 'readState', 'reviewMode'] as const
|
|
136
137
|
for (const key of booleans) {
|
|
137
138
|
const entry = value[key]
|
|
138
139
|
if (entry !== undefined && typeof entry !== 'boolean') {
|
|
@@ -149,6 +150,23 @@ export function parsePresetOptionsSettings(value: unknown): PresetOptionsSetting
|
|
|
149
150
|
|| estimatorTimeoutMs < 100 || estimatorTimeoutMs > 60_000)) {
|
|
150
151
|
throw new TypeError('Context-compression presetOptions.estimatorTimeoutMs must be an integer between 100 and 60000')
|
|
151
152
|
}
|
|
153
|
+
const reviewTimeoutTurns = value.reviewTimeoutTurns
|
|
154
|
+
if (reviewTimeoutTurns !== undefined
|
|
155
|
+
&& (typeof reviewTimeoutTurns !== 'number' || !Number.isSafeInteger(reviewTimeoutTurns) || reviewTimeoutTurns < 1)) {
|
|
156
|
+
throw new TypeError('Context-compression presetOptions.reviewTimeoutTurns must be an integer of at least 1')
|
|
157
|
+
}
|
|
158
|
+
const cacheHitDiscountAlpha = value.cacheHitDiscountAlpha
|
|
159
|
+
if (cacheHitDiscountAlpha !== undefined
|
|
160
|
+
&& (typeof cacheHitDiscountAlpha !== 'number' || !Number.isFinite(cacheHitDiscountAlpha)
|
|
161
|
+
|| cacheHitDiscountAlpha <= 0 || cacheHitDiscountAlpha >= 1)) {
|
|
162
|
+
throw new TypeError('Context-compression presetOptions.cacheHitDiscountAlpha must be a number strictly between 0 and 1')
|
|
163
|
+
}
|
|
164
|
+
const reviewHighImpactTokens = value.reviewHighImpactTokens
|
|
165
|
+
if (reviewHighImpactTokens !== undefined
|
|
166
|
+
&& (typeof reviewHighImpactTokens !== 'number' || !Number.isSafeInteger(reviewHighImpactTokens)
|
|
167
|
+
|| reviewHighImpactTokens < 0)) {
|
|
168
|
+
throw new TypeError('Context-compression presetOptions.reviewHighImpactTokens must be a non-negative integer')
|
|
169
|
+
}
|
|
152
170
|
for (const key of ['estimatorProvider', 'estimatorModel', 'estimatorBaseUrl', 'estimatorApiKey'] as const) {
|
|
153
171
|
const entry = value[key]
|
|
154
172
|
if (entry !== undefined && typeof entry !== 'string') {
|
|
@@ -168,6 +186,10 @@ export function parsePresetOptionsSettings(value: unknown): PresetOptionsSetting
|
|
|
168
186
|
if (value.estimatorBaseUrl !== undefined) result.estimatorBaseUrl = value.estimatorBaseUrl as string
|
|
169
187
|
if (value.estimatorApiKey !== undefined) result.estimatorApiKey = value.estimatorApiKey as string
|
|
170
188
|
if (estimatorTimeoutMs !== undefined) result.estimatorTimeoutMs = estimatorTimeoutMs as number
|
|
189
|
+
if (value.reviewMode !== undefined) result.reviewMode = value.reviewMode as boolean
|
|
190
|
+
if (reviewTimeoutTurns !== undefined) result.reviewTimeoutTurns = reviewTimeoutTurns as number
|
|
191
|
+
if (cacheHitDiscountAlpha !== undefined) result.cacheHitDiscountAlpha = cacheHitDiscountAlpha as number
|
|
192
|
+
if (reviewHighImpactTokens !== undefined) result.reviewHighImpactTokens = reviewHighImpactTokens as number
|
|
171
193
|
return result
|
|
172
194
|
}
|
|
173
195
|
|
|
@@ -415,6 +437,12 @@ const PRESET_OPTION_DEFAULTS: PresetOptions = deepFreeze({
|
|
|
415
437
|
prefixStabilizer: true,
|
|
416
438
|
readState: true,
|
|
417
439
|
estimator: { mode: '' },
|
|
440
|
+
// Review pipeline (beta) ships off: pending proposals never block the
|
|
441
|
+
// automatic path until the user opts in.
|
|
442
|
+
reviewMode: false,
|
|
443
|
+
reviewTimeoutTurns: 6,
|
|
444
|
+
cacheHitDiscountAlpha: 0.1,
|
|
445
|
+
reviewHighImpactTokens: 4000,
|
|
418
446
|
})
|
|
419
447
|
|
|
420
448
|
/**
|
|
@@ -432,6 +460,10 @@ function mergePresetOptions(overrides: PresetOptionsSettings | undefined): Prese
|
|
|
432
460
|
prefixStabilizer: overrides.prefixStabilizer ?? PRESET_OPTION_DEFAULTS.prefixStabilizer,
|
|
433
461
|
readState: overrides.readState ?? PRESET_OPTION_DEFAULTS.readState,
|
|
434
462
|
estimator: { mode: overrides.estimatorMode ?? PRESET_OPTION_DEFAULTS.estimator.mode },
|
|
463
|
+
reviewMode: overrides.reviewMode ?? PRESET_OPTION_DEFAULTS.reviewMode,
|
|
464
|
+
reviewTimeoutTurns: overrides.reviewTimeoutTurns ?? PRESET_OPTION_DEFAULTS.reviewTimeoutTurns,
|
|
465
|
+
cacheHitDiscountAlpha: overrides.cacheHitDiscountAlpha ?? PRESET_OPTION_DEFAULTS.cacheHitDiscountAlpha,
|
|
466
|
+
reviewHighImpactTokens: overrides.reviewHighImpactTokens ?? PRESET_OPTION_DEFAULTS.reviewHighImpactTokens,
|
|
435
467
|
})
|
|
436
468
|
}
|
|
437
469
|
|