@namzu/sdk 62.8.0 → 62.9.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/CHANGELOG.md +20 -0
- package/dist/compaction/plan.d.ts +12 -0
- package/dist/compaction/plan.d.ts.map +1 -1
- package/dist/compaction/plan.js +39 -11
- package/dist/compaction/plan.js.map +1 -1
- package/dist/compaction/salience/minhash.d.ts +9 -0
- package/dist/compaction/salience/minhash.d.ts.map +1 -1
- package/dist/compaction/salience/minhash.js +20 -0
- package/dist/compaction/salience/minhash.js.map +1 -1
- package/dist/compaction/salience/score.d.ts.map +1 -1
- package/dist/compaction/salience/score.js +87 -45
- package/dist/compaction/salience/score.js.map +1 -1
- package/dist/compaction/salience/working-set.d.ts +14 -0
- package/dist/compaction/salience/working-set.d.ts.map +1 -1
- package/dist/compaction/salience/working-set.js +84 -33
- package/dist/compaction/salience/working-set.js.map +1 -1
- package/dist/config/runtime.d.ts +2 -0
- package/dist/config/runtime.d.ts.map +1 -1
- package/dist/config/runtime.js +14 -0
- package/dist/config/runtime.js.map +1 -1
- package/dist/manager/session/turn-recorder.d.ts +25 -0
- package/dist/manager/session/turn-recorder.d.ts.map +1 -1
- package/dist/manager/session/turn-recorder.js +193 -17
- package/dist/manager/session/turn-recorder.js.map +1 -1
- package/dist/public-runtime.d.ts +1 -1
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +1 -1
- package/dist/public-runtime.js.map +1 -1
- package/dist/runtime/query/cancelled-before-start.js +1 -1
- package/dist/runtime/query/cancelled-before-start.js.map +1 -1
- package/dist/runtime/query/checkpoint.js +2 -1
- package/dist/runtime/query/checkpoint.js.map +1 -1
- package/dist/runtime/query/index.d.ts.map +1 -1
- package/dist/runtime/query/index.js +4 -1
- package/dist/runtime/query/index.js.map +1 -1
- package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/compaction.js +15 -1
- package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
- package/dist/runtime/query/prepare-turn.js +10 -4
- package/dist/runtime/query/prepare-turn.js.map +1 -1
- package/dist/store/session-log/core.d.ts +31 -1
- package/dist/store/session-log/core.d.ts.map +1 -1
- package/dist/store/session-log/core.js +95 -9
- package/dist/store/session-log/core.js.map +1 -1
- package/dist/store/session-log/disk.d.ts +2 -0
- package/dist/store/session-log/disk.d.ts.map +1 -1
- package/dist/store/session-log/disk.js +37 -0
- package/dist/store/session-log/disk.js.map +1 -1
- package/dist/store/session-log/fold.d.ts +9 -1
- package/dist/store/session-log/fold.d.ts.map +1 -1
- package/dist/store/session-log/fold.js +18 -2
- package/dist/store/session-log/fold.js.map +1 -1
- package/dist/store/session-log/incremental.d.ts +8 -0
- package/dist/store/session-log/incremental.d.ts.map +1 -1
- package/dist/store/session-log/incremental.js +7 -2
- package/dist/store/session-log/incremental.js.map +1 -1
- package/dist/store/session-log/index.d.ts +1 -0
- package/dist/store/session-log/index.d.ts.map +1 -1
- package/dist/store/session-log/index.js +1 -0
- package/dist/store/session-log/index.js.map +1 -1
- package/dist/store/session-log/verified-prefix.d.ts +46 -0
- package/dist/store/session-log/verified-prefix.d.ts.map +1 -0
- package/dist/store/session-log/verified-prefix.js +233 -0
- package/dist/store/session-log/verified-prefix.js.map +1 -0
- package/dist/types/session/records.d.ts +80 -1
- package/dist/types/session/records.d.ts.map +1 -1
- package/dist/types/session/records.js +22 -0
- package/dist/types/session/records.js.map +1 -1
- package/package.json +1 -1
- package/src/compaction/plan.ts +53 -11
- package/src/compaction/salience/minhash.ts +20 -0
- package/src/compaction/salience/score.ts +112 -43
- package/src/compaction/salience/working-set.ts +102 -31
- package/src/config/runtime.ts +14 -0
- package/src/manager/session/turn-recorder.ts +218 -15
- package/src/public-runtime.ts +1 -0
- package/src/runtime/query/cancelled-before-start.ts +1 -1
- package/src/runtime/query/checkpoint.ts +2 -1
- package/src/runtime/query/index.ts +4 -1
- package/src/runtime/query/iteration/phases/compaction.ts +18 -0
- package/src/runtime/query/prepare-turn.ts +10 -3
- package/src/store/session-log/core.ts +129 -10
- package/src/store/session-log/disk.ts +32 -0
- package/src/store/session-log/fold.ts +19 -2
- package/src/store/session-log/incremental.ts +15 -2
- package/src/store/session-log/index.ts +1 -0
- package/src/store/session-log/verified-prefix.ts +243 -0
- package/src/types/session/records.ts +25 -0
package/src/compaction/plan.ts
CHANGED
|
@@ -4,7 +4,12 @@ import { resolveTriggerThreshold } from './context-window.js'
|
|
|
4
4
|
import { findSafeTrimIndex } from './dangling.js'
|
|
5
5
|
import { buildGoal } from './salience/goal.js'
|
|
6
6
|
import { scoreMessages } from './salience/score.js'
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
type WorkingSetPlan,
|
|
9
|
+
hasEvictableMessage,
|
|
10
|
+
planNothingToShed,
|
|
11
|
+
planWorkingSet,
|
|
12
|
+
} from './salience/working-set.js'
|
|
8
13
|
import { estimateMessageTokens, estimateMessagesTokens } from './token-estimate.js'
|
|
9
14
|
import { clearStaleToolResults } from './tool-result-editing.js'
|
|
10
15
|
|
|
@@ -136,6 +141,33 @@ export function naiveKeepStartByTokens(messages: readonly Message[], budgetToken
|
|
|
136
141
|
/** Where the salience pass holds the context, as a fraction of the window. */
|
|
137
142
|
export const DEFAULT_SOFT_TARGET = 0.5
|
|
138
143
|
|
|
144
|
+
/**
|
|
145
|
+
* The context size, in tokens, a salience pass sheds down to: the soft
|
|
146
|
+
* target less the configured headroom. With no headroom (the default) this
|
|
147
|
+
* is the soft target, computed exactly as it always was.
|
|
148
|
+
*/
|
|
149
|
+
export function salienceTargetTokens(config: CompactionConfig, windowTokens: number): number {
|
|
150
|
+
const soft = config.softTarget ?? DEFAULT_SOFT_TARGET
|
|
151
|
+
const headroom = config.softTargetHeadroom ?? 0
|
|
152
|
+
return Math.floor(windowTokens * (headroom > 0 ? Math.max(0, soft - headroom) : soft))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Whether a salience pass could shed anything from `messages` at all: false
|
|
157
|
+
* is exact (the pass would make no action whatever the scores are), true is
|
|
158
|
+
* "go and score". See {@link hasEvictableMessage}.
|
|
159
|
+
*/
|
|
160
|
+
export function canShedSalienceWorkingSet(
|
|
161
|
+
messages: readonly Message[],
|
|
162
|
+
config: CompactionConfig,
|
|
163
|
+
): boolean {
|
|
164
|
+
return hasEvictableMessage(messages, {
|
|
165
|
+
minToolResultChars: config.minToolResultCharsToClear,
|
|
166
|
+
...(config.preserveToolResultsFrom ? { preserveTools: config.preserveToolResultsFrom } : {}),
|
|
167
|
+
keepRecentMessages: config.keepRecentMessages,
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
139
171
|
export function planSalienceWorkingSet(input: {
|
|
140
172
|
readonly messages: readonly Message[]
|
|
141
173
|
readonly config: CompactionConfig
|
|
@@ -146,19 +178,29 @@ export function planSalienceWorkingSet(input: {
|
|
|
146
178
|
readonly openTasks?: readonly string[]
|
|
147
179
|
}): Extract<CompactionPlan, { kind: 'cleared' }> & { readonly working: WorkingSetPlan } {
|
|
148
180
|
const { config, estimatedTokens, contextWindowTokens: budget } = input
|
|
149
|
-
const
|
|
150
|
-
...(input.openTasks ? { openTasks: input.openTasks } : {}),
|
|
151
|
-
})
|
|
152
|
-
const scored = scoreMessages(input.messages, {
|
|
153
|
-
goal,
|
|
154
|
-
keepRecentMessages: config.keepRecentMessages,
|
|
155
|
-
})
|
|
156
|
-
const working = planWorkingSet(input.messages, scored, {
|
|
181
|
+
const workingOptions = {
|
|
157
182
|
estimatedTokens,
|
|
158
|
-
targetTokens:
|
|
183
|
+
targetTokens: salienceTargetTokens(config, budget),
|
|
159
184
|
minToolResultChars: config.minToolResultCharsToClear,
|
|
160
185
|
...(config.preserveToolResultsFrom ? { preserveTools: config.preserveToolResultsFrom } : {}),
|
|
161
|
-
}
|
|
186
|
+
}
|
|
187
|
+
// Nothing the pass may shed, whatever the scores say: every tool result
|
|
188
|
+
// is small, cleared, an error or from a preserved tool, and every
|
|
189
|
+
// narration is short or already cut. Scoring every message to learn that
|
|
190
|
+
// is the whole cost of a pass that can do nothing, and it ran on every
|
|
191
|
+
// turn from the soft target until the summary trigger.
|
|
192
|
+
const working = canShedSalienceWorkingSet(input.messages, config)
|
|
193
|
+
? planWorkingSet(
|
|
194
|
+
input.messages,
|
|
195
|
+
scoreMessages(input.messages, {
|
|
196
|
+
goal: buildGoal(input.messages, {
|
|
197
|
+
...(input.openTasks ? { openTasks: input.openTasks } : {}),
|
|
198
|
+
}),
|
|
199
|
+
keepRecentMessages: config.keepRecentMessages,
|
|
200
|
+
}),
|
|
201
|
+
workingOptions,
|
|
202
|
+
)
|
|
203
|
+
: planNothingToShed(input.messages, workingOptions)
|
|
162
204
|
return {
|
|
163
205
|
kind: 'cleared',
|
|
164
206
|
messages: working.messages,
|
|
@@ -48,6 +48,26 @@ export function similarity(a: Uint32Array, b: Uint32Array): number {
|
|
|
48
48
|
return same / HASHES
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* {@link similarity}, or 0 when it is below `floor`.
|
|
53
|
+
*
|
|
54
|
+
* The scorer reads a similarity below one half as no similarity at all, so
|
|
55
|
+
* a comparison that has already missed enough slots to land under the floor
|
|
56
|
+
* can stop there. For a pair that reaches the floor the answer is the same
|
|
57
|
+
* number `similarity` returns, slot for slot.
|
|
58
|
+
*/
|
|
59
|
+
export function similarityAtLeast(a: Uint32Array, b: Uint32Array, floor: number): number {
|
|
60
|
+
const allowedMisses = HASHES - Math.ceil(floor * HASHES)
|
|
61
|
+
let misses = 0
|
|
62
|
+
for (let slot = 0; slot < HASHES; slot += 1) {
|
|
63
|
+
if (a[slot] !== b[slot]) {
|
|
64
|
+
misses += 1
|
|
65
|
+
if (misses > allowedMisses) return 0
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return (HASHES - misses) / HASHES
|
|
69
|
+
}
|
|
70
|
+
|
|
51
71
|
export function isEmptySignature(signature: Uint32Array): boolean {
|
|
52
72
|
return signature.every((h) => h === 0xffffffff)
|
|
53
73
|
}
|
|
@@ -26,7 +26,7 @@ import type { AssistantMessage, Message, ToolMessage } from '../../types/message
|
|
|
26
26
|
import { findRetainedIndices } from '../retention.js'
|
|
27
27
|
import { estimateMessageTokens } from '../token-estimate.js'
|
|
28
28
|
import { type Bm25Document, bm25Score, buildIndex, indexDocument } from './bm25.js'
|
|
29
|
-
import { isEmptySignature, minhash,
|
|
29
|
+
import { isEmptySignature, minhash, similarityAtLeast } from './minhash.js'
|
|
30
30
|
import { tokenize } from './tokenize.js'
|
|
31
31
|
|
|
32
32
|
export interface SalienceWeights {
|
|
@@ -119,6 +119,54 @@ function citable(tokens: readonly string[]): Set<string> {
|
|
|
119
119
|
return out
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
/**
|
|
123
|
+
* What the scorer derives from one message's text, and nothing that depends
|
|
124
|
+
* on the rest of the history: the term frequencies BM25 reads, the
|
|
125
|
+
* identifiers a later turn could cite, the MinHash signature and a key for
|
|
126
|
+
* "byte-identical signature".
|
|
127
|
+
*
|
|
128
|
+
* A pass scored every message from its raw text every time, and the text of
|
|
129
|
+
* a message that is already in the history does not change between passes.
|
|
130
|
+
* The cost of a pass was therefore proportional to every byte in the
|
|
131
|
+
* history, paid again on each pass, and the largest part of it was hashing
|
|
132
|
+
* shingles. Held per message OBJECT, a pass pays for the messages it has not
|
|
133
|
+
* seen and for the arithmetic that does depend on the whole history (the
|
|
134
|
+
* corpus statistics, recency, the redundancy comparison).
|
|
135
|
+
*
|
|
136
|
+
* Keyed by object identity and validated by the text it was computed from: a
|
|
137
|
+
* message that is replaced (a stub is a copy) is a different object, and one
|
|
138
|
+
* that is edited in place fails the text comparison, so a stale entry is
|
|
139
|
+
* never read. Weakly held: an entry goes with its message.
|
|
140
|
+
*/
|
|
141
|
+
interface MessageFeatures {
|
|
142
|
+
readonly text: string
|
|
143
|
+
readonly tokenCount: number
|
|
144
|
+
readonly document: Bm25Document
|
|
145
|
+
readonly citable: ReadonlySet<string>
|
|
146
|
+
readonly signature: Uint32Array
|
|
147
|
+
/** Equal keys mean equal signatures, so a similarity of exactly 1. */
|
|
148
|
+
readonly signatureKey: string
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const FEATURES = new WeakMap<Message, MessageFeatures>()
|
|
152
|
+
|
|
153
|
+
function featuresOf(message: Message, text: string): MessageFeatures {
|
|
154
|
+
const cached = FEATURES.get(message)
|
|
155
|
+
if (cached !== undefined && cached.text === text) return cached
|
|
156
|
+
const tokens = tokenize(text)
|
|
157
|
+
const signature = minhash(tokens)
|
|
158
|
+
const features: MessageFeatures = {
|
|
159
|
+
text,
|
|
160
|
+
tokenCount: tokens.length,
|
|
161
|
+
document: indexDocument(text, tokens),
|
|
162
|
+
citable: citable(tokens),
|
|
163
|
+
signature,
|
|
164
|
+
signatureKey: String.fromCharCode(...new Uint16Array(signature.buffer)),
|
|
165
|
+
}
|
|
166
|
+
FEATURES.set(message, features)
|
|
167
|
+
return features
|
|
168
|
+
}
|
|
169
|
+
|
|
122
170
|
export function scoreMessages(
|
|
123
171
|
messages: readonly Message[],
|
|
124
172
|
options: ScoreOptions,
|
|
@@ -129,12 +177,9 @@ export function scoreMessages(
|
|
|
129
177
|
weights: { ...DEFAULT_SALIENCE_WEIGHTS, ...options.config?.weights },
|
|
130
178
|
}
|
|
131
179
|
const n = messages.length
|
|
132
|
-
const
|
|
180
|
+
const features = messages.map((message) => featuresOf(message, messageText(message)))
|
|
133
181
|
const richContent = messages.map(hasRichContent)
|
|
134
|
-
const
|
|
135
|
-
const documents: Bm25Document[] = texts.map((text, i) =>
|
|
136
|
-
indexDocument(text, tokenLists[i] as string[]),
|
|
137
|
-
)
|
|
182
|
+
const documents: Bm25Document[] = features.map((f) => f.document)
|
|
138
183
|
const index = buildIndex(documents)
|
|
139
184
|
const goalTokens = tokenize(options.goal)
|
|
140
185
|
|
|
@@ -156,33 +201,64 @@ export function scoreMessages(
|
|
|
156
201
|
)
|
|
157
202
|
|
|
158
203
|
// Citation index: everything a LATER assistant turn or tool input names.
|
|
159
|
-
//
|
|
160
|
-
// cite my identifiers"
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
204
|
+
// For each identifier, the last message that names it: a message asks
|
|
205
|
+
// "does anything after me cite my identifiers" as "is the last citation
|
|
206
|
+
// of this identifier after me". The same answer as a set of everything
|
|
207
|
+
// after each message, without building n sets of up to n identifiers.
|
|
208
|
+
const lastCited = new Map<string, number>()
|
|
209
|
+
for (let i = 0; i < n; i += 1) {
|
|
165
210
|
const message = messages[i] as Message
|
|
166
|
-
if (message.role
|
|
167
|
-
|
|
168
|
-
}
|
|
211
|
+
if (message.role !== 'assistant' && message.role !== 'user') continue
|
|
212
|
+
for (const token of (features[i] as MessageFeatures).citable) lastCited.set(token, i)
|
|
169
213
|
}
|
|
170
214
|
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
// message's tool call exactly.
|
|
174
|
-
const signatures = tokenLists.map(minhash)
|
|
215
|
+
// A full demotion when a later assistant turn repeats this message's
|
|
216
|
+
// tool call exactly: for each call, the last message that makes it.
|
|
175
217
|
const callKeys = messages.map((m) =>
|
|
176
218
|
isAssistantWithCalls(m)
|
|
177
219
|
? (m.toolCalls ?? []).map((c) => `${c.function.name}:${c.function.arguments}`)
|
|
178
220
|
: [],
|
|
179
221
|
)
|
|
180
|
-
const
|
|
181
|
-
let
|
|
222
|
+
const lastCalled = new Map<string, number>()
|
|
223
|
+
for (let i = 0; i < n; i += 1) {
|
|
224
|
+
for (const key of callKeys[i] as string[]) lastCalled.set(key, i)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Redundancy: the highest similarity to any later message of the same
|
|
228
|
+
// role. Walked from the end so the later messages of each role are the
|
|
229
|
+
// ones already seen; a signature identical to one of them is a
|
|
230
|
+
// similarity of exactly 1, the most there is, and ends the scan.
|
|
231
|
+
// Below the lower of one half and the duplicate threshold a similarity
|
|
232
|
+
// is read as none (see the redundancy step of the score), so the
|
|
233
|
+
// comparison need not finish it.
|
|
234
|
+
const floor = Math.min(0.5, config.duplicateThreshold)
|
|
235
|
+
const redundancyOf: number[] = new Array(n).fill(0)
|
|
236
|
+
const laterSignatures = new Map<Message['role'], Uint32Array[]>()
|
|
237
|
+
const laterKeys = new Map<Message['role'], Set<string>>()
|
|
182
238
|
for (let i = n - 1; i >= 0; i -= 1) {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
239
|
+
const message = messages[i] as Message
|
|
240
|
+
// A caption or a repeated call cannot prove that two screenshots or
|
|
241
|
+
// documents contain the same evidence. The lexical scorer has no
|
|
242
|
+
// access to their meaning, so it must not call those messages duplicates.
|
|
243
|
+
if (richContent[i]) continue
|
|
244
|
+
const own = features[i] as MessageFeatures
|
|
245
|
+
const sameRole = laterSignatures.get(message.role) ?? []
|
|
246
|
+
const sameRoleKeys = laterKeys.get(message.role) ?? new Set<string>()
|
|
247
|
+
let redundancy = 0
|
|
248
|
+
if (!isEmptySignature(own.signature)) {
|
|
249
|
+
if (sameRoleKeys.has(own.signatureKey)) redundancy = 1
|
|
250
|
+
else {
|
|
251
|
+
for (const other of sameRole) {
|
|
252
|
+
const sim = similarityAtLeast(own.signature, other, floor)
|
|
253
|
+
if (sim > redundancy) redundancy = sim
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
redundancyOf[i] = redundancy
|
|
258
|
+
sameRole.push(own.signature)
|
|
259
|
+
sameRoleKeys.add(own.signatureKey)
|
|
260
|
+
laterSignatures.set(message.role, sameRole)
|
|
261
|
+
laterKeys.set(message.role, sameRoleKeys)
|
|
186
262
|
}
|
|
187
263
|
|
|
188
264
|
// Protection.
|
|
@@ -239,8 +315,8 @@ export function scoreMessages(
|
|
|
239
315
|
// An instruction is used by everything after it; an acknowledgment
|
|
240
316
|
// ("ok, go on") is not an instruction. The difference is whether it
|
|
241
317
|
// names anything or says more than a few words.
|
|
242
|
-
const
|
|
243
|
-
utility = citable
|
|
318
|
+
const own = features[i] as MessageFeatures
|
|
319
|
+
utility = own.citable.size > 0 || own.tokenCount >= 6 ? 1 : 0.2
|
|
244
320
|
} else if (message.role === 'tool') {
|
|
245
321
|
// Used is used: one identifier from this result named by a later
|
|
246
322
|
// turn is the evidence, and a count would let the recency of
|
|
@@ -248,24 +324,17 @@ export function scoreMessages(
|
|
|
248
324
|
// for. The eval that placed the account id deep in an early dump
|
|
249
325
|
// and cited it later watched the salience pass clear it under a
|
|
250
326
|
// thirds rule.
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
327
|
+
let cited = false
|
|
328
|
+
for (const token of (features[i] as MessageFeatures).citable) {
|
|
329
|
+
if ((lastCited.get(token) ?? -1) > i) {
|
|
330
|
+
cited = true
|
|
331
|
+
break
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
utility = cited ? 1 : 0
|
|
255
335
|
} else if (isAssistantWithCalls(message)) utility = 0.5
|
|
256
336
|
|
|
257
|
-
let redundancy =
|
|
258
|
-
const signature = signatures[i] as Uint32Array
|
|
259
|
-
// A caption or a repeated call cannot prove that two screenshots or
|
|
260
|
-
// documents contain the same evidence. The lexical scorer has no
|
|
261
|
-
// access to their meaning, so it must not call those messages duplicates.
|
|
262
|
-
if (!richContent[i] && !isEmptySignature(signature)) {
|
|
263
|
-
for (let j = i + 1; j < n; j += 1) {
|
|
264
|
-
if (richContent[j] || (messages[j] as Message).role !== message.role) continue
|
|
265
|
-
const sim = similarity(signature, signatures[j] as Uint32Array)
|
|
266
|
-
if (sim > redundancy) redundancy = sim
|
|
267
|
-
}
|
|
268
|
-
}
|
|
337
|
+
let redundancy = redundancyOf[i] as number
|
|
269
338
|
if (redundancy < config.duplicateThreshold) redundancy = redundancy >= 0.5 ? redundancy : 0
|
|
270
339
|
else redundancy = 1
|
|
271
340
|
if (message.role === 'tool' && !richContent[i]) {
|
|
@@ -279,7 +348,7 @@ export function scoreMessages(
|
|
|
279
348
|
)
|
|
280
349
|
if (call) {
|
|
281
350
|
const key = `${call.function.name}:${call.function.arguments}`
|
|
282
|
-
if ((
|
|
351
|
+
if ((lastCalled.get(key) ?? -1) > i) redundancy = 1
|
|
283
352
|
break
|
|
284
353
|
}
|
|
285
354
|
}
|
|
@@ -73,14 +73,18 @@ export function isStubbedNarration(content: unknown): boolean {
|
|
|
73
73
|
return typeof content === 'string' && content.includes(STUB_MARK)
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
|
|
76
|
+
/** What decides whether a message is something the pass may shed at all. */
|
|
77
|
+
interface EvictionRules {
|
|
78
|
+
readonly minTool: number
|
|
79
|
+
readonly minNarration: number
|
|
80
|
+
readonly preserve: ReadonlySet<string>
|
|
81
|
+
readonly toolNameByCallId: ReadonlyMap<string, string>
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function evictionRules(
|
|
77
85
|
messages: readonly Message[],
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
): WorkingSetPlan {
|
|
81
|
-
const minTool = options.minToolResultChars ?? 1_000
|
|
82
|
-
const minNarration = options.minNarrationChars ?? 240
|
|
83
|
-
const preserve = new Set(options.preserveTools ?? [])
|
|
86
|
+
options: Pick<WorkingSetOptions, 'minToolResultChars' | 'minNarrationChars' | 'preserveTools'>,
|
|
87
|
+
): EvictionRules {
|
|
84
88
|
const toolNameByCallId = new Map<string, string>()
|
|
85
89
|
for (const m of messages) {
|
|
86
90
|
if (m.role !== 'assistant') continue
|
|
@@ -88,6 +92,87 @@ export function planWorkingSet(
|
|
|
88
92
|
toolNameByCallId.set(call.id, call.function.name)
|
|
89
93
|
}
|
|
90
94
|
}
|
|
95
|
+
return {
|
|
96
|
+
minTool: options.minToolResultChars ?? 1_000,
|
|
97
|
+
minNarration: options.minNarrationChars ?? 240,
|
|
98
|
+
preserve: new Set(options.preserveTools ?? []),
|
|
99
|
+
toolNameByCallId,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Whether the pass could clear or stub this message, ignoring only what
|
|
105
|
+
* protection decides (the floor, the recent turns, retained turns, a pair
|
|
106
|
+
* with a protected half) and what the score decides. The one place the
|
|
107
|
+
* eligibility rules live: {@link planWorkingSet} asks it of each unprotected
|
|
108
|
+
* message, and {@link hasEvictableMessage} asks it of every message.
|
|
109
|
+
*/
|
|
110
|
+
function evictionKind(m: Message, tokens: number, rules: EvictionRules): 'clear' | 'stub' | null {
|
|
111
|
+
if (tokens === 0) return null
|
|
112
|
+
if (m.role === 'tool') {
|
|
113
|
+
const tool = m as ToolMessage
|
|
114
|
+
if (tool.isError || isClearedToolResult(tool.content)) return null
|
|
115
|
+
if (rules.preserve.has(rules.toolNameByCallId.get(tool.toolCallId) ?? '')) return null
|
|
116
|
+
if (tokens * CHARS_PER_TOKEN < rules.minTool) return null
|
|
117
|
+
return 'clear'
|
|
118
|
+
}
|
|
119
|
+
if (m.role === 'assistant' && !Array.isArray((m as AssistantMessage).toolCalls)) {
|
|
120
|
+
const text = typeof m.content === 'string' ? m.content : ''
|
|
121
|
+
if (text.length < rules.minNarration || isStubbedNarration(text)) return null
|
|
122
|
+
return 'stub'
|
|
123
|
+
}
|
|
124
|
+
return null
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* A cheap count of whether anything at all is left for the pass to shed:
|
|
129
|
+
* false means {@link planWorkingSet} would return no action whatever the
|
|
130
|
+
* scores are, so scoring can be skipped. It leaves out the two protections
|
|
131
|
+
* that are only a position (the leading system run and the last
|
|
132
|
+
* `keepRecentMessages`); retained turns, tool-call pairs and the goal floor
|
|
133
|
+
* are not looked at. True is therefore only "go and look", and a false is
|
|
134
|
+
* exact.
|
|
135
|
+
*/
|
|
136
|
+
export function hasEvictableMessage(
|
|
137
|
+
messages: readonly Message[],
|
|
138
|
+
options: Pick<WorkingSetOptions, 'minToolResultChars' | 'minNarrationChars' | 'preserveTools'> & {
|
|
139
|
+
readonly keepRecentMessages: number
|
|
140
|
+
},
|
|
141
|
+
): boolean {
|
|
142
|
+
const rules = evictionRules(messages, options)
|
|
143
|
+
let floorEnd = 0
|
|
144
|
+
while (floorEnd < messages.length && messages[floorEnd]?.role === 'system') floorEnd += 1
|
|
145
|
+
const end = Math.max(floorEnd, messages.length - options.keepRecentMessages)
|
|
146
|
+
for (let i = floorEnd; i < end; i += 1) {
|
|
147
|
+
const m = messages[i] as Message
|
|
148
|
+
if (evictionKind(m, estimateMessageTokens(m), rules) !== null) return true
|
|
149
|
+
}
|
|
150
|
+
return false
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The plan of a pass that sheds nothing: every message as it was. */
|
|
154
|
+
export function planNothingToShed(
|
|
155
|
+
messages: readonly Message[],
|
|
156
|
+
options: Pick<WorkingSetOptions, 'estimatedTokens' | 'targetTokens'>,
|
|
157
|
+
): WorkingSetPlan {
|
|
158
|
+
return {
|
|
159
|
+
messages: [...messages],
|
|
160
|
+
actions: [],
|
|
161
|
+
clearedCount: 0,
|
|
162
|
+
stubbedCount: 0,
|
|
163
|
+
charsReclaimed: 0,
|
|
164
|
+
reclaimedTokens: 0,
|
|
165
|
+
reachedTarget: options.estimatedTokens - options.targetTokens <= 0,
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function planWorkingSet(
|
|
170
|
+
messages: readonly Message[],
|
|
171
|
+
scored: readonly ScoredMessage[],
|
|
172
|
+
options: WorkingSetOptions,
|
|
173
|
+
): WorkingSetPlan {
|
|
174
|
+
const rules = evictionRules(messages, options)
|
|
175
|
+
const { toolNameByCallId } = rules
|
|
91
176
|
|
|
92
177
|
// Ordered by salience, not salience per token. Per token was the plan's
|
|
93
178
|
// first answer and it evicts the wrong thing: a large result the goal
|
|
@@ -104,31 +189,17 @@ export function planWorkingSet(
|
|
|
104
189
|
}
|
|
105
190
|
const candidates: Candidate[] = []
|
|
106
191
|
for (const s of scored) {
|
|
107
|
-
if (s.protected !== null
|
|
192
|
+
if (s.protected !== null) continue
|
|
108
193
|
const m = messages[s.index] as Message
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
tokens: s.tokens,
|
|
119
|
-
goalBound: s.relevance >= 0.5 || s.utility >= 1,
|
|
120
|
-
})
|
|
121
|
-
} else if (m.role === 'assistant' && !Array.isArray((m as AssistantMessage).toolCalls)) {
|
|
122
|
-
const text = typeof m.content === 'string' ? m.content : ''
|
|
123
|
-
if (text.length < minNarration || isStubbedNarration(text)) continue
|
|
124
|
-
candidates.push({
|
|
125
|
-
index: s.index,
|
|
126
|
-
kind: 'stub',
|
|
127
|
-
salience: s.salience,
|
|
128
|
-
tokens: s.tokens,
|
|
129
|
-
goalBound: s.relevance >= 0.5 || s.utility >= 1,
|
|
130
|
-
})
|
|
131
|
-
}
|
|
194
|
+
const kind = evictionKind(m, s.tokens, rules)
|
|
195
|
+
if (kind === null) continue
|
|
196
|
+
candidates.push({
|
|
197
|
+
index: s.index,
|
|
198
|
+
kind,
|
|
199
|
+
salience: s.salience,
|
|
200
|
+
tokens: s.tokens,
|
|
201
|
+
goalBound: s.relevance >= 0.5 || s.utility >= 1,
|
|
202
|
+
})
|
|
132
203
|
}
|
|
133
204
|
candidates.sort((a, b) => a.salience - b.salience || b.tokens - a.tokens || a.index - b.index)
|
|
134
205
|
const maxSalience = candidates.reduce((m, c) => Math.max(m, c.salience), Number.NEGATIVE_INFINITY)
|
package/src/config/runtime.ts
CHANGED
|
@@ -32,6 +32,20 @@ export const CompactionConfigSchema = z.object({
|
|
|
32
32
|
* `triggerThreshold` summarises. The context is held around here.
|
|
33
33
|
*/
|
|
34
34
|
softTarget: z.number().min(0).max(1).optional(),
|
|
35
|
+
/**
|
|
36
|
+
* `salience` only: how far below `softTarget`, as a fraction of the
|
|
37
|
+
* window, a pass sheds. A pass still starts when the context reaches the
|
|
38
|
+
* soft target, but it then keeps shedding until the context is
|
|
39
|
+
* `softTargetHeadroom` of the window under it, so the next pass is needed
|
|
40
|
+
* only after that headroom has been used instead of on the next turn.
|
|
41
|
+
* Fewer passes, and the request is rewritten (a prompt cache's prefix
|
|
42
|
+
* broken) once per headroom instead of once per turn; the cost is that
|
|
43
|
+
* the context is held that much lower, so that much less history stays
|
|
44
|
+
* whole. The messages shed are the same ones in the same order, only
|
|
45
|
+
* earlier. Absent or `0`, the default, a pass sheds to the soft target
|
|
46
|
+
* and no further, exactly as before.
|
|
47
|
+
*/
|
|
48
|
+
softTargetHeadroom: z.number().min(0).max(1).optional(),
|
|
35
49
|
/**
|
|
36
50
|
* Optional model context-window size (tokens) the compaction trigger
|
|
37
51
|
* measures the CURRENT window against. NO default: when omitted the
|