@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.
Files changed (110) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/compaction/plan.d.ts +12 -0
  3. package/dist/compaction/plan.d.ts.map +1 -1
  4. package/dist/compaction/plan.js +39 -11
  5. package/dist/compaction/plan.js.map +1 -1
  6. package/dist/compaction/salience/minhash.d.ts +9 -0
  7. package/dist/compaction/salience/minhash.d.ts.map +1 -1
  8. package/dist/compaction/salience/minhash.js +20 -0
  9. package/dist/compaction/salience/minhash.js.map +1 -1
  10. package/dist/compaction/salience/score.d.ts.map +1 -1
  11. package/dist/compaction/salience/score.js +87 -45
  12. package/dist/compaction/salience/score.js.map +1 -1
  13. package/dist/compaction/salience/working-set.d.ts +14 -0
  14. package/dist/compaction/salience/working-set.d.ts.map +1 -1
  15. package/dist/compaction/salience/working-set.js +84 -33
  16. package/dist/compaction/salience/working-set.js.map +1 -1
  17. package/dist/config/runtime.d.ts +2 -0
  18. package/dist/config/runtime.d.ts.map +1 -1
  19. package/dist/config/runtime.js +14 -0
  20. package/dist/config/runtime.js.map +1 -1
  21. package/dist/connector/http-operation.d.ts +1 -0
  22. package/dist/connector/http-operation.d.ts.map +1 -1
  23. package/dist/connector/http-operation.js +1 -1
  24. package/dist/connector/http-operation.js.map +1 -1
  25. package/dist/connector/mcp/adapter.d.ts.map +1 -1
  26. package/dist/connector/mcp/adapter.js +10 -0
  27. package/dist/connector/mcp/adapter.js.map +1 -1
  28. package/dist/connector/mcp/client.d.ts +12 -0
  29. package/dist/connector/mcp/client.d.ts.map +1 -1
  30. package/dist/connector/mcp/client.js +59 -10
  31. package/dist/connector/mcp/client.js.map +1 -1
  32. package/dist/constants/mcp/index.d.ts +15 -0
  33. package/dist/constants/mcp/index.d.ts.map +1 -1
  34. package/dist/constants/mcp/index.js +15 -0
  35. package/dist/constants/mcp/index.js.map +1 -1
  36. package/dist/manager/session/turn-recorder.d.ts +25 -0
  37. package/dist/manager/session/turn-recorder.d.ts.map +1 -1
  38. package/dist/manager/session/turn-recorder.js +193 -17
  39. package/dist/manager/session/turn-recorder.js.map +1 -1
  40. package/dist/public-runtime.d.ts +1 -1
  41. package/dist/public-runtime.d.ts.map +1 -1
  42. package/dist/public-runtime.js +1 -1
  43. package/dist/public-runtime.js.map +1 -1
  44. package/dist/runtime/query/cancelled-before-start.js +1 -1
  45. package/dist/runtime/query/cancelled-before-start.js.map +1 -1
  46. package/dist/runtime/query/checkpoint.js +2 -1
  47. package/dist/runtime/query/checkpoint.js.map +1 -1
  48. package/dist/runtime/query/index.d.ts.map +1 -1
  49. package/dist/runtime/query/index.js +4 -1
  50. package/dist/runtime/query/index.js.map +1 -1
  51. package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
  52. package/dist/runtime/query/iteration/phases/compaction.js +15 -1
  53. package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
  54. package/dist/runtime/query/prepare-turn.js +10 -4
  55. package/dist/runtime/query/prepare-turn.js.map +1 -1
  56. package/dist/store/session-log/core.d.ts +31 -1
  57. package/dist/store/session-log/core.d.ts.map +1 -1
  58. package/dist/store/session-log/core.js +95 -9
  59. package/dist/store/session-log/core.js.map +1 -1
  60. package/dist/store/session-log/disk.d.ts +2 -0
  61. package/dist/store/session-log/disk.d.ts.map +1 -1
  62. package/dist/store/session-log/disk.js +37 -0
  63. package/dist/store/session-log/disk.js.map +1 -1
  64. package/dist/store/session-log/fold.d.ts +9 -1
  65. package/dist/store/session-log/fold.d.ts.map +1 -1
  66. package/dist/store/session-log/fold.js +18 -2
  67. package/dist/store/session-log/fold.js.map +1 -1
  68. package/dist/store/session-log/incremental.d.ts +8 -0
  69. package/dist/store/session-log/incremental.d.ts.map +1 -1
  70. package/dist/store/session-log/incremental.js +7 -2
  71. package/dist/store/session-log/incremental.js.map +1 -1
  72. package/dist/store/session-log/index.d.ts +1 -0
  73. package/dist/store/session-log/index.d.ts.map +1 -1
  74. package/dist/store/session-log/index.js +1 -0
  75. package/dist/store/session-log/index.js.map +1 -1
  76. package/dist/store/session-log/verified-prefix.d.ts +46 -0
  77. package/dist/store/session-log/verified-prefix.d.ts.map +1 -0
  78. package/dist/store/session-log/verified-prefix.js +233 -0
  79. package/dist/store/session-log/verified-prefix.js.map +1 -0
  80. package/dist/types/connector/mcp.d.ts +19 -0
  81. package/dist/types/connector/mcp.d.ts.map +1 -1
  82. package/dist/types/session/records.d.ts +80 -1
  83. package/dist/types/session/records.d.ts.map +1 -1
  84. package/dist/types/session/records.js +22 -0
  85. package/dist/types/session/records.js.map +1 -1
  86. package/package.json +1 -1
  87. package/src/compaction/plan.ts +53 -11
  88. package/src/compaction/salience/minhash.ts +20 -0
  89. package/src/compaction/salience/score.ts +112 -43
  90. package/src/compaction/salience/working-set.ts +102 -31
  91. package/src/config/runtime.ts +14 -0
  92. package/src/connector/http-operation.ts +1 -1
  93. package/src/connector/mcp/adapter.ts +11 -0
  94. package/src/connector/mcp/client.ts +75 -12
  95. package/src/constants/mcp/index.ts +17 -0
  96. package/src/manager/session/turn-recorder.ts +218 -15
  97. package/src/public-runtime.ts +1 -0
  98. package/src/runtime/query/cancelled-before-start.ts +1 -1
  99. package/src/runtime/query/checkpoint.ts +2 -1
  100. package/src/runtime/query/index.ts +4 -1
  101. package/src/runtime/query/iteration/phases/compaction.ts +18 -0
  102. package/src/runtime/query/prepare-turn.ts +10 -3
  103. package/src/store/session-log/core.ts +129 -10
  104. package/src/store/session-log/disk.ts +32 -0
  105. package/src/store/session-log/fold.ts +19 -2
  106. package/src/store/session-log/incremental.ts +15 -2
  107. package/src/store/session-log/index.ts +1 -0
  108. package/src/store/session-log/verified-prefix.ts +243 -0
  109. package/src/types/connector/mcp.ts +19 -0
  110. 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, similarity } from './minhash.js'
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 texts = messages.map(messageText)
180
+ const features = messages.map((message) => featuresOf(message, messageText(message)))
133
181
  const richContent = messages.map(hasRichContent)
134
- const tokenLists = texts.map(tokenize)
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
- // Built once, suffix-wise, so each message asks "does anything after me
160
- // cite my identifiers" in one lookup.
161
- const citedAfter: Set<string>[] = new Array(n)
162
- let running = new Set<string>()
163
- for (let i = n - 1; i >= 0; i -= 1) {
164
- citedAfter[i] = running
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 === 'assistant' || message.role === 'user') {
167
- running = new Set([...running, ...citable(tokenLists[i] as string[])])
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
- // Redundancy: the highest similarity to any later message of the same
172
- // role, and a full demotion when a later assistant turn repeats this
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 laterCallKeys: Set<string>[] = new Array(n)
181
- let calls = new Set<string>()
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
- laterCallKeys[i] = calls
184
- if ((callKeys[i] as string[]).length > 0)
185
- calls = new Set([...calls, ...(callKeys[i] as string[])])
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 tokens = tokenLists[i] as string[]
243
- utility = citable(tokens).size > 0 || tokens.length >= 6 ? 1 : 0.2
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
- const mine = citable(tokenLists[i] as string[])
252
- let hits = 0
253
- for (const token of mine) if ((citedAfter[i] as Set<string>).has(token)) hits += 1
254
- utility = hits > 0 ? 1 : 0
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 = 0
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 ((laterCallKeys[i] as Set<string>).has(key)) redundancy = 1
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
- export function planWorkingSet(
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
- scored: readonly ScoredMessage[],
79
- options: WorkingSetOptions,
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 || s.tokens === 0) continue
192
+ if (s.protected !== null) continue
108
193
  const m = messages[s.index] as Message
109
- if (m.role === 'tool') {
110
- const tool = m as ToolMessage
111
- if (tool.isError || isClearedToolResult(tool.content)) continue
112
- if (preserve.has(toolNameByCallId.get(tool.toolCallId) ?? '')) continue
113
- if (s.tokens * CHARS_PER_TOKEN < minTool) continue
114
- candidates.push({
115
- index: s.index,
116
- kind: 'clear',
117
- salience: s.salience,
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)
@@ -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,4 +1,4 @@
1
- const MAX_TIMER_DELAY_MS = 2_147_483_647
1
+ export const MAX_TIMER_DELAY_MS = 2_147_483_647
2
2
  const MAX_RESPONSE_BYTES = 2_147_483_647
3
3
 
4
4
  // These headers can change where an already-validated URL is routed. Connector
@@ -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 { validateConnectorTimeoutMs } from '../http-operation.js'
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
- { onProgress: (update: MCPProgressUpdate) => void; lastProgress?: number }
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(timer)
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
- if (progressToken !== undefined && options?.onProgress) {
1308
- this.progressRequests.set(progressToken, { onProgress: options.onProgress })
1309
- }
1310
- const timer = setTimeout(() => {
1311
- const error = new Error(
1312
- `MCP request "${method}" to "${this.config.serverName}" timed out after ${this.requestTimeoutMs}ms`,
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': this.requestTimeoutMs,
1361
+ 'namzu.connector.timeout_ms': limit,
1362
+ 'namzu.connector.deadline': deadline,
1319
1363
  })
1320
1364
  settle('timeout', error)
1321
- }, this.requestTimeoutMs)
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