@namzu/sdk 62.8.0 → 62.10.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 +26 -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/connector/http-operation.d.ts +1 -0
- package/dist/connector/http-operation.d.ts.map +1 -1
- package/dist/connector/http-operation.js +1 -1
- package/dist/connector/http-operation.js.map +1 -1
- package/dist/connector/mcp/adapter.d.ts.map +1 -1
- package/dist/connector/mcp/adapter.js +10 -0
- package/dist/connector/mcp/adapter.js.map +1 -1
- package/dist/connector/mcp/client.d.ts +12 -0
- package/dist/connector/mcp/client.d.ts.map +1 -1
- package/dist/connector/mcp/client.js +59 -10
- package/dist/connector/mcp/client.js.map +1 -1
- package/dist/constants/mcp/index.d.ts +15 -0
- package/dist/constants/mcp/index.d.ts.map +1 -1
- package/dist/constants/mcp/index.js +15 -0
- package/dist/constants/mcp/index.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/connector/mcp.d.ts +19 -0
- package/dist/types/connector/mcp.d.ts.map +1 -1
- 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/connector/http-operation.ts +1 -1
- package/src/connector/mcp/adapter.ts +11 -0
- package/src/connector/mcp/client.ts +75 -12
- package/src/constants/mcp/index.ts +17 -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/connector/mcp.ts +19 -0
- package/src/types/session/records.ts +25 -0
|
@@ -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
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
import type { WorkingStatePin } from '../../compaction/types.js'
|
|
3
|
+
import { MCP_TOOL_DEADLINE_SLACK_MS } from '../../constants/mcp/index.js'
|
|
3
4
|
import { zodToJsonSchema } from '../../registry/tool/json-schema.js'
|
|
4
5
|
import { type UntrustedEnvelope, wrapUntrusted } from '../../tools/untrusted-envelope.js'
|
|
5
6
|
import type {
|
|
@@ -9,6 +10,7 @@ import type {
|
|
|
9
10
|
} from '../../types/connector/index.js'
|
|
10
11
|
import type { ToolResultBlock, ToolResultContent } from '../../types/message/index.js'
|
|
11
12
|
import type { ToolContext, ToolDefinition, ToolResult } from '../../types/tool/index.js'
|
|
13
|
+
import { MAX_TIMER_DELAY_MS as MAX_MCP_TIMER_DELAY_MS } from '../http-operation.js'
|
|
12
14
|
import { admitMcpAudioBatch } from './audio-admission.js'
|
|
13
15
|
import type { MCPClient } from './client.js'
|
|
14
16
|
import { MCPInputRequiredError, isMissingRequiredClientCapabilityError } from './errors.js'
|
|
@@ -503,6 +505,12 @@ export function mcpToolToToolDefinition(
|
|
|
503
505
|
maxRetries?: number,
|
|
504
506
|
): ToolDefinition {
|
|
505
507
|
const inputSchema = mcpJsonSchemaToZod(tool.inputSchema)
|
|
508
|
+
// A call the client keeps alive on progress must not be cut by the
|
|
509
|
+
// executor's own per-tool deadline (120 s unless the host says otherwise)
|
|
510
|
+
// first. Only with `resetTimeoutOnProgress`; otherwise no `timeoutMs`, so
|
|
511
|
+
// every existing host keeps the executor's default.
|
|
512
|
+
const callWindowMs =
|
|
513
|
+
typeof client.getToolCallWindowMs === 'function' ? client.getToolCallWindowMs() : undefined
|
|
506
514
|
const toolName = `mcp_${serverName}_${tool.name}`
|
|
507
515
|
|
|
508
516
|
// Everything the server's own annotations carry that `isReadOnly`/
|
|
@@ -544,6 +552,9 @@ export function mcpToolToToolDefinition(
|
|
|
544
552
|
category: 'network',
|
|
545
553
|
permissions: ['network_access'],
|
|
546
554
|
...(maxRetries === undefined ? {} : { maxRetries }),
|
|
555
|
+
...(callWindowMs === undefined
|
|
556
|
+
? {}
|
|
557
|
+
: { timeoutMs: Math.min(callWindowMs + MCP_TOOL_DEADLINE_SLACK_MS, MAX_MCP_TIMER_DELAY_MS) }),
|
|
547
558
|
// Reports what the SERVER said, faithfully. The outbound re-export
|
|
548
559
|
// and the destructive label a human is shown both need the server's
|
|
549
560
|
// own answer; whether a gate may act on it is decided separately,
|
|
@@ -34,7 +34,10 @@ import { generateMCPClientId } from '../../utils/id.js'
|
|
|
34
34
|
import type { LogAttributes } from '../../utils/log/index.js'
|
|
35
35
|
import { SCOPE_ATTRIBUTE } from '../../utils/log/types.js'
|
|
36
36
|
import { type Logger, resolveLogger } from '../../utils/logger.js'
|
|
37
|
-
import {
|
|
37
|
+
import {
|
|
38
|
+
MAX_TIMER_DELAY_MS as MAX_MCP_TIMER_DELAY_MS,
|
|
39
|
+
validateConnectorTimeoutMs,
|
|
40
|
+
} from '../http-operation.js'
|
|
38
41
|
import { buildEnvelope, decodeResult } from './envelope.js'
|
|
39
42
|
import {
|
|
40
43
|
type McpEraProbeAnswer,
|
|
@@ -60,6 +63,7 @@ import { type McpParamHeaderBinding, validateMcpHeaderAnnotations } from './x-mc
|
|
|
60
63
|
import {
|
|
61
64
|
DEFAULT_MCP_ERA_PROBE_TIMEOUT_MS,
|
|
62
65
|
DEFAULT_MCP_REQUEST_TIMEOUT_MS,
|
|
66
|
+
DEFAULT_MCP_TOTAL_TIMEOUT_FACTOR,
|
|
63
67
|
JSON_RPC_INVALID_PARAMS,
|
|
64
68
|
JSON_RPC_METHOD_NOT_FOUND,
|
|
65
69
|
MCP_DISCOVER_METHOD,
|
|
@@ -236,7 +240,12 @@ export class MCPClient {
|
|
|
236
240
|
private nextRequestId = 1
|
|
237
241
|
private progressRequests = new Map<
|
|
238
242
|
string,
|
|
239
|
-
{
|
|
243
|
+
{
|
|
244
|
+
onProgress: (update: MCPProgressUpdate) => void
|
|
245
|
+
/** Called when a progress value is accepted; re-arms the idle deadline. */
|
|
246
|
+
onAccepted?: () => void
|
|
247
|
+
lastProgress?: number
|
|
248
|
+
}
|
|
240
249
|
>()
|
|
241
250
|
private notificationHandlers: Array<(method: string, params?: Record<string, unknown>) => void> =
|
|
242
251
|
[]
|
|
@@ -251,6 +260,9 @@ export class MCPClient {
|
|
|
251
260
|
private log: Logger
|
|
252
261
|
private readonly config: MCPClientConfig
|
|
253
262
|
private readonly requestTimeoutMs: number
|
|
263
|
+
private readonly resetTimeoutOnProgress: boolean
|
|
264
|
+
/** Total cap for a progress-extended call; meaningful only when `resetTimeoutOnProgress`. */
|
|
265
|
+
private readonly maxTotalTimeoutMs: number
|
|
254
266
|
private readonly eraCache: MCPEraCache
|
|
255
267
|
private readonly eraCacheKey: string
|
|
256
268
|
private readonly eraProbeTimeoutMs: number
|
|
@@ -265,12 +277,36 @@ export class MCPClient {
|
|
|
265
277
|
*/
|
|
266
278
|
private toolParamHeaders: Map<string, readonly McpParamHeaderBinding[]> = new Map()
|
|
267
279
|
|
|
280
|
+
/**
|
|
281
|
+
* The longest a `tools/call` can legitimately take on this client, or
|
|
282
|
+
* `undefined` when it has only the fixed request deadline.
|
|
283
|
+
*
|
|
284
|
+
* Defined only with `resetTimeoutOnProgress`: the total cap. A layer that
|
|
285
|
+
* bounds the call from outside (the executor's per-tool deadline) needs it
|
|
286
|
+
* to avoid cutting a call this client would have kept alive.
|
|
287
|
+
*/
|
|
288
|
+
getToolCallWindowMs(): number | undefined {
|
|
289
|
+
return this.resetTimeoutOnProgress ? this.maxTotalTimeoutMs : undefined
|
|
290
|
+
}
|
|
291
|
+
|
|
268
292
|
constructor(config: MCPClientConfig) {
|
|
269
293
|
this.config = config
|
|
270
294
|
this.requestTimeoutMs = validateConnectorTimeoutMs(
|
|
271
295
|
config.requestTimeoutMs ?? DEFAULT_MCP_REQUEST_TIMEOUT_MS,
|
|
272
296
|
'MCPClient requestTimeoutMs',
|
|
273
297
|
)
|
|
298
|
+
this.resetTimeoutOnProgress = config.resetTimeoutOnProgress === true
|
|
299
|
+
const maxTotal = validateConnectorTimeoutMs(
|
|
300
|
+
config.maxTotalTimeoutMs ??
|
|
301
|
+
Math.min(this.requestTimeoutMs * DEFAULT_MCP_TOTAL_TIMEOUT_FACTOR, MAX_MCP_TIMER_DELAY_MS),
|
|
302
|
+
'MCPClient maxTotalTimeoutMs',
|
|
303
|
+
)
|
|
304
|
+
if (maxTotal < this.requestTimeoutMs) {
|
|
305
|
+
throw new Error(
|
|
306
|
+
`MCPClient maxTotalTimeoutMs must not be below requestTimeoutMs (${this.requestTimeoutMs}); received ${maxTotal}`,
|
|
307
|
+
)
|
|
308
|
+
}
|
|
309
|
+
this.maxTotalTimeoutMs = maxTotal
|
|
274
310
|
// Never longer than one round trip's deadline: a probe IS a request,
|
|
275
311
|
// and one that outlived the bound every other request is held to
|
|
276
312
|
// would be a connect that hangs past its own timeout.
|
|
@@ -1209,6 +1245,8 @@ export class MCPClient {
|
|
|
1209
1245
|
options?.signal?.throwIfAborted()
|
|
1210
1246
|
const id = this.nextRequestId++
|
|
1211
1247
|
const progressToken = method === 'tools/call' && options?.onProgress ? randomUUID() : undefined
|
|
1248
|
+
// Only a call that can receive progress can have its deadline extended.
|
|
1249
|
+
const extendable = this.resetTimeoutOnProgress && progressToken !== undefined
|
|
1212
1250
|
const requestParams =
|
|
1213
1251
|
progressToken === undefined
|
|
1214
1252
|
? params
|
|
@@ -1252,8 +1290,11 @@ export class MCPClient {
|
|
|
1252
1290
|
const onCallerAbort = (): void => {
|
|
1253
1291
|
settle('caller', options?.signal?.reason)
|
|
1254
1292
|
}
|
|
1293
|
+
let idleTimer: ReturnType<typeof setTimeout> | undefined
|
|
1294
|
+
let capTimer: ReturnType<typeof setTimeout> | undefined
|
|
1255
1295
|
const cleanup = (): void => {
|
|
1256
|
-
clearTimeout(
|
|
1296
|
+
clearTimeout(idleTimer)
|
|
1297
|
+
clearTimeout(capTimer)
|
|
1257
1298
|
options?.signal?.removeEventListener('abort', onCallerAbort)
|
|
1258
1299
|
if (this.pendingRequests.get(id) === entry) this.pendingRequests.delete(id)
|
|
1259
1300
|
if (progressToken !== undefined) this.progressRequests.delete(progressToken)
|
|
@@ -1304,21 +1345,40 @@ export class MCPClient {
|
|
|
1304
1345
|
}
|
|
1305
1346
|
|
|
1306
1347
|
this.pendingRequests.set(id, entry)
|
|
1307
|
-
|
|
1308
|
-
this.
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1348
|
+
const expire = (deadline: 'request' | 'idle' | 'total_cap'): void => {
|
|
1349
|
+
const limit = deadline === 'total_cap' ? this.maxTotalTimeoutMs : this.requestTimeoutMs
|
|
1350
|
+
const what =
|
|
1351
|
+
deadline === 'request'
|
|
1352
|
+
? `timed out after ${limit}ms`
|
|
1353
|
+
: deadline === 'idle'
|
|
1354
|
+
? `timed out after ${limit}ms with no progress`
|
|
1355
|
+
: `timed out at its total limit of ${limit}ms`
|
|
1356
|
+
const error = new Error(`MCP request "${method}" to "${this.config.serverName}" ${what}`)
|
|
1314
1357
|
error.name = 'TimeoutError'
|
|
1315
1358
|
this.log.warn('MCP request timed out', {
|
|
1316
1359
|
'namzu.connector.server': this.config.serverName,
|
|
1317
1360
|
'namzu.connector.method': method,
|
|
1318
|
-
'namzu.connector.timeout_ms':
|
|
1361
|
+
'namzu.connector.timeout_ms': limit,
|
|
1362
|
+
'namzu.connector.deadline': deadline,
|
|
1319
1363
|
})
|
|
1320
1364
|
settle('timeout', error)
|
|
1321
|
-
}
|
|
1365
|
+
}
|
|
1366
|
+
const armIdle = (): void => {
|
|
1367
|
+
clearTimeout(idleTimer)
|
|
1368
|
+
idleTimer = setTimeout(() => expire(extendable ? 'idle' : 'request'), this.requestTimeoutMs)
|
|
1369
|
+
}
|
|
1370
|
+
if (progressToken !== undefined && options?.onProgress) {
|
|
1371
|
+
this.progressRequests.set(progressToken, {
|
|
1372
|
+
onProgress: options.onProgress,
|
|
1373
|
+
// A settled request has already cleared both timers and its
|
|
1374
|
+
// registration, so a late notification cannot re-arm anything.
|
|
1375
|
+
...(extendable ? { onAccepted: () => !settled && armIdle() } : {}),
|
|
1376
|
+
})
|
|
1377
|
+
}
|
|
1378
|
+
armIdle()
|
|
1379
|
+
if (extendable) {
|
|
1380
|
+
capTimer = setTimeout(() => expire('total_cap'), this.maxTotalTimeoutMs)
|
|
1381
|
+
}
|
|
1322
1382
|
options?.signal?.addEventListener('abort', onCallerAbort, { once: true })
|
|
1323
1383
|
// AbortSignal events are not replayed. This closes the listener-install
|
|
1324
1384
|
// race before the request is issued.
|
|
@@ -1685,6 +1745,9 @@ export class MCPClient {
|
|
|
1685
1745
|
if (typeof progress !== 'number' || !Number.isFinite(progress) || progress < 0) return
|
|
1686
1746
|
if (pending.lastProgress !== undefined && progress <= pending.lastProgress) return
|
|
1687
1747
|
pending.lastProgress = progress
|
|
1748
|
+
// Re-arms before the callback runs, so a throwing `onProgress` still
|
|
1749
|
+
// counts as progress; the total cap bounds that.
|
|
1750
|
+
pending.onAccepted?.()
|
|
1688
1751
|
const total = params?.total
|
|
1689
1752
|
const message = progressMessage(params?.message)
|
|
1690
1753
|
const update: MCPProgressUpdate = {
|
|
@@ -70,6 +70,23 @@ export const MCP_SUPPORTED_PROTOCOL_VERSIONS: readonly string[] = [
|
|
|
70
70
|
*/
|
|
71
71
|
export const DEFAULT_MCP_REQUEST_TIMEOUT_MS = 30_000
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* How many request deadlines the total cap is when `resetTimeoutOnProgress`
|
|
75
|
+
* is on and the caller gave no `maxTotalTimeoutMs`: ten times
|
|
76
|
+
* `requestTimeoutMs` (5 minutes at the default), never above Node's maximum
|
|
77
|
+
* timer delay. Long enough for work that reports progress for minutes,
|
|
78
|
+
* short enough that a server which reports forever is still cut off.
|
|
79
|
+
*/
|
|
80
|
+
export const DEFAULT_MCP_TOTAL_TIMEOUT_FACTOR = 10
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Headroom added to the client's total cap when a bridged tool carries its
|
|
84
|
+
* own executor deadline, so the client's error (which names the deadline and
|
|
85
|
+
* sends the cancellation) is the one a caller sees rather than the
|
|
86
|
+
* executor's generic cut-off racing it.
|
|
87
|
+
*/
|
|
88
|
+
export const MCP_TOOL_DEADLINE_SLACK_MS = 5_000
|
|
89
|
+
|
|
73
90
|
/** JSON-RPC 2.0 reserved code for parameters the receiver cannot accept. */
|
|
74
91
|
export const JSON_RPC_INVALID_PARAMS = -32602
|
|
75
92
|
|