@namzu/sdk 62.9.0 → 62.11.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 (81) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/bridge/a2a/mapper.d.ts.map +1 -1
  3. package/dist/bridge/a2a/mapper.js +1 -0
  4. package/dist/bridge/a2a/mapper.js.map +1 -1
  5. package/dist/bridge/sse/mapper.d.ts.map +1 -1
  6. package/dist/bridge/sse/mapper.js +9 -0
  7. package/dist/bridge/sse/mapper.js.map +1 -1
  8. package/dist/compaction/loaded-skills.d.ts +77 -0
  9. package/dist/compaction/loaded-skills.d.ts.map +1 -0
  10. package/dist/compaction/loaded-skills.js +203 -0
  11. package/dist/compaction/loaded-skills.js.map +1 -0
  12. package/dist/compaction/manager.d.ts +10 -0
  13. package/dist/compaction/manager.d.ts.map +1 -1
  14. package/dist/compaction/manager.js +20 -0
  15. package/dist/compaction/manager.js.map +1 -1
  16. package/dist/compaction/manual.d.ts.map +1 -1
  17. package/dist/compaction/manual.js +17 -3
  18. package/dist/compaction/manual.js.map +1 -1
  19. package/dist/compaction/restore.d.ts +5 -0
  20. package/dist/compaction/restore.d.ts.map +1 -1
  21. package/dist/compaction/restore.js +18 -18
  22. package/dist/compaction/restore.js.map +1 -1
  23. package/dist/connector/http-operation.d.ts +1 -0
  24. package/dist/connector/http-operation.d.ts.map +1 -1
  25. package/dist/connector/http-operation.js +1 -1
  26. package/dist/connector/http-operation.js.map +1 -1
  27. package/dist/connector/mcp/adapter.d.ts.map +1 -1
  28. package/dist/connector/mcp/adapter.js +10 -0
  29. package/dist/connector/mcp/adapter.js.map +1 -1
  30. package/dist/connector/mcp/client.d.ts +12 -0
  31. package/dist/connector/mcp/client.d.ts.map +1 -1
  32. package/dist/connector/mcp/client.js +59 -10
  33. package/dist/connector/mcp/client.js.map +1 -1
  34. package/dist/constants/mcp/index.d.ts +15 -0
  35. package/dist/constants/mcp/index.d.ts.map +1 -1
  36. package/dist/constants/mcp/index.js +15 -0
  37. package/dist/constants/mcp/index.js.map +1 -1
  38. package/dist/contracts/session/api.d.ts +1 -1
  39. package/dist/contracts/session/api.d.ts.map +1 -1
  40. package/dist/execution/workflow/phases.d.ts +5 -2
  41. package/dist/execution/workflow/phases.d.ts.map +1 -1
  42. package/dist/execution/workflow/phases.js +32 -8
  43. package/dist/execution/workflow/phases.js.map +1 -1
  44. package/dist/runtime/query/iteration/phases/compaction.d.ts +1 -1
  45. package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
  46. package/dist/runtime/query/iteration/phases/compaction.js +76 -11
  47. package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
  48. package/dist/tools/builtins/skill.d.ts.map +1 -1
  49. package/dist/tools/builtins/skill.js +5 -0
  50. package/dist/tools/builtins/skill.js.map +1 -1
  51. package/dist/turn/reporter.d.ts.map +1 -1
  52. package/dist/turn/reporter.js +9 -0
  53. package/dist/turn/reporter.js.map +1 -1
  54. package/dist/types/connector/mcp.d.ts +19 -0
  55. package/dist/types/connector/mcp.d.ts.map +1 -1
  56. package/dist/types/session/events.d.ts +26 -3
  57. package/dist/types/session/events.d.ts.map +1 -1
  58. package/dist/types/session/events.js +4 -1
  59. package/dist/types/session/events.js.map +1 -1
  60. package/dist/types/session/records.d.ts.map +1 -1
  61. package/dist/types/session/records.js +1 -0
  62. package/dist/types/session/records.js.map +1 -1
  63. package/package.json +1 -1
  64. package/src/bridge/a2a/mapper.ts +1 -0
  65. package/src/bridge/sse/mapper.ts +10 -0
  66. package/src/compaction/loaded-skills.ts +245 -0
  67. package/src/compaction/manager.ts +24 -0
  68. package/src/compaction/manual.ts +20 -2
  69. package/src/compaction/restore.ts +33 -18
  70. package/src/connector/http-operation.ts +1 -1
  71. package/src/connector/mcp/adapter.ts +11 -0
  72. package/src/connector/mcp/client.ts +75 -12
  73. package/src/constants/mcp/index.ts +17 -0
  74. package/src/contracts/session/api.ts +1 -0
  75. package/src/execution/workflow/phases.ts +34 -8
  76. package/src/runtime/query/iteration/phases/compaction.ts +102 -16
  77. package/src/tools/builtins/skill.ts +5 -0
  78. package/src/turn/reporter.ts +10 -0
  79. package/src/types/connector/mcp.ts +19 -0
  80. package/src/types/session/events.ts +27 -2
  81. package/src/types/session/records.ts +1 -0
@@ -123,6 +123,30 @@ export class WorkingStateManager {
123
123
  while (restorable.skills.length > MAX_RESTORABLE_SKILLS) restorable.skills.shift()
124
124
  }
125
125
 
126
+ /**
127
+ * Take in skills the conversation shows that this manager did not see load
128
+ * (a turn that began from a history, or a resumed one). They count as older
129
+ * than anything this manager recorded itself, and never replace it.
130
+ * `skills` is oldest first.
131
+ */
132
+ adoptSkillBodies(skills: readonly { readonly name: string; readonly body: string }[]): void {
133
+ const missing = skills.filter(
134
+ (skill) =>
135
+ skill.name.trim().length > 0 &&
136
+ skill.body.trim().length > 0 &&
137
+ !this.state.restorable?.skills.some((held) => held.name === skill.name),
138
+ )
139
+ if (missing.length === 0) return
140
+ const restorable = this.restorableState()
141
+ restorable.skills.unshift(
142
+ ...missing.map((skill) => ({
143
+ name: skill.name,
144
+ body: skill.body.slice(0, MAX_RESTORABLE_CHARS),
145
+ })),
146
+ )
147
+ while (restorable.skills.length > MAX_RESTORABLE_SKILLS) restorable.skills.shift()
148
+ }
149
+
126
150
  /** Remember what a read returned so a compaction can put it back. */
127
151
  recordFileText(path: string, text: string): void {
128
152
  const key = normalizeRestorablePath(path)
@@ -15,6 +15,7 @@ import {
15
15
  extractFromToolResult,
16
16
  extractFromUserMessage,
17
17
  } from './extractor.js'
18
+ import { adoptableSkills, handledSkillNames } from './loaded-skills.js'
18
19
  import { WorkingStateManager } from './manager.js'
19
20
  import {
20
21
  type CompactionSummaryRouter,
@@ -156,6 +157,7 @@ function withRestoredContext(
156
157
  body: string,
157
158
  manager: WorkingStateManager,
158
159
  input: CompactNowInput,
160
+ kept: readonly Message[],
159
161
  ): string {
160
162
  const window = resolveContextWindow(
161
163
  input.contextWindowTokens ?? input.config.contextWindowTokens,
@@ -163,6 +165,8 @@ function withRestoredContext(
163
165
  )
164
166
  const restored = renderRestoredContext(manager.getState(), {
165
167
  contextWindowTokens: window.tokens,
168
+ // Instructions already whole in what the pass keeps are not put back twice.
169
+ wholeSkills: handledSkillNames(kept),
166
170
  ...(input.restoreNote ? { hostNote: input.restoreNote } : {}),
167
171
  })
168
172
  return restored ? `${body}\n\n${restored}` : body
@@ -175,6 +179,9 @@ function populateWorkingState(
175
179
  config: CompactionConfig,
176
180
  ): void {
177
181
  let firstUser = true
182
+ // Skills an earlier pass put back (an entry, or a summary's restored
183
+ // section) are not tool results, so they are read from the messages whole.
184
+ manager.adoptSkillBodies(adoptableSkills(messages))
178
185
  const toolNames = new Map<string, string>()
179
186
  const toolInputs = new Map<string, unknown>()
180
187
 
@@ -283,7 +290,11 @@ export async function compactNow(input: CompactNowInput): Promise<CompactionResu
283
290
 
284
291
  const { messages, summary } = splice(
285
292
  preservedSystem,
286
- withRestoredContext(body, manager, input),
293
+ withRestoredContext(body, manager, input, [
294
+ ...preservedSystem,
295
+ ...retainedOlder,
296
+ ...plan.recentMessages,
297
+ ]),
287
298
  retainedOlder,
288
299
  plan.recentMessages,
289
300
  input.recoveryNote,
@@ -379,7 +390,14 @@ export async function compactRegion(input: CompactRegionInput): Promise<Compacti
379
390
  // Same cross-run ownership as compactNow: a selected region has been
380
391
  // replaced outside a turn, so its only surviving account is pinned.
381
392
  const summary = {
382
- ...buildCompactionMessage(withRestoredContext(body, manager, input), input.recoveryNote),
393
+ ...buildCompactionMessage(
394
+ withRestoredContext(body, manager, input, [
395
+ ...messages.slice(0, start),
396
+ ...retainedSelected,
397
+ ...messages.slice(end),
398
+ ]),
399
+ input.recoveryNote,
400
+ ),
383
401
  retain: true,
384
402
  }
385
403
  const out = [...messages.slice(0, start), summary, ...retainedSelected, ...messages.slice(end)]
@@ -1,7 +1,18 @@
1
1
  import { resolve as resolvePath } from 'node:path'
2
2
 
3
3
  import { CHARS_PER_TOKEN } from '../constants/limits.js'
4
+ import {
5
+ CARRY_MAX_SKILLS,
6
+ CARRY_SKILLS_TOTAL_TOKENS,
7
+ CARRY_SKILLS_WINDOW_SHARE,
8
+ CARRY_SKILL_TOKENS,
9
+ type RenderedSkills,
10
+ isForkedSkillReport,
11
+ renderLoadedSkills,
12
+ skillResultBody,
13
+ } from './loaded-skills.js'
4
14
  import type { WorkingStateManager } from './manager.js'
15
+ import { isClearedToolResult } from './tool-result-editing.js'
5
16
  import type { WorkingState } from './types.js'
6
17
 
7
18
  /**
@@ -16,11 +27,11 @@ import type { WorkingState } from './types.js'
16
27
  */
17
28
 
18
29
  /** Skills restored per pass, most recent first. */
19
- export const RESTORE_MAX_SKILLS = 8
30
+ export const RESTORE_MAX_SKILLS = CARRY_MAX_SKILLS
20
31
  /** Tokens of one restored skill. */
21
- export const RESTORE_SKILL_TOKENS = 5_000
32
+ export const RESTORE_SKILL_TOKENS = CARRY_SKILL_TOKENS
22
33
  /** Tokens of all restored skills together. */
23
- export const RESTORE_SKILLS_TOTAL_TOKENS = 25_000
34
+ export const RESTORE_SKILLS_TOTAL_TOKENS = CARRY_SKILLS_TOTAL_TOKENS
24
35
  /** Files restored per pass, most recent first. */
25
36
  export const RESTORE_MAX_FILES = 5
26
37
  /** Tokens of one restored file. */
@@ -30,10 +41,9 @@ export const RESTORE_FILES_TOTAL_TOKENS = 50_000
30
41
  /** The share of the window all restored files may take. */
31
42
  export const RESTORE_FILES_WINDOW_SHARE = 0.1
32
43
  /** The share of the window all restored skills may take. */
33
- export const RESTORE_SKILLS_WINDOW_SHARE = 0.05
44
+ export const RESTORE_SKILLS_WINDOW_SHARE = CARRY_SKILLS_WINDOW_SHARE
34
45
 
35
46
  const CUT_MARKER = '\n… [cut to fit the restore budget]'
36
- const SKILL_CONTINUATION = /\n\n\[More skill instructions remain\.[^\]]*\]/g
37
47
 
38
48
  function isInstructionPath(path: string): boolean {
39
49
  const normal = path.replace(/\\/g, '/')
@@ -73,9 +83,13 @@ export function recordRestorable(
73
83
  if (!input) return
74
84
  if (call.toolName === 'skill') {
75
85
  if (!call.success || typeof input.name !== 'string') return
86
+ // A cleared result is a placeholder, not the instructions.
87
+ if (isClearedToolResult(call.output)) return
88
+ // A delegated agent's report is not the skill's instructions.
89
+ if (isForkedSkillReport(call.output)) return
76
90
  manager.recordSkillBody(
77
91
  input.name,
78
- call.output.replace(SKILL_CONTINUATION, ''),
92
+ skillResultBody(call.output),
79
93
  typeof input.cursor === 'string' && input.cursor.length > 0,
80
94
  )
81
95
  return
@@ -105,6 +119,10 @@ export interface RestoreRenderOptions {
105
119
  readonly contextWindowTokens?: number
106
120
  /** A host's own line to carry across, such as where its plan file is. */
107
121
  readonly hostNote?: string
122
+ /** Skills whose instructions are already whole in the messages that follow the summary; not shown again. */
123
+ readonly wholeSkills?: ReadonlySet<string>
124
+ /** Told which skills were put back, which were cut and which were left out. */
125
+ readonly onSkills?: (rendered: RenderedSkills) => void
108
126
  }
109
127
 
110
128
  /** The section to append after a summary, or `undefined` when there is nothing to put back. */
@@ -113,10 +131,6 @@ export function renderRestoredContext(
113
131
  options: RestoreRenderOptions = {},
114
132
  ): string | undefined {
115
133
  const window = options.contextWindowTokens
116
- const skillsTotal =
117
- window === undefined
118
- ? RESTORE_SKILLS_TOTAL_TOKENS
119
- : Math.min(RESTORE_SKILLS_TOTAL_TOKENS, Math.floor(window * RESTORE_SKILLS_WINDOW_SHARE))
120
134
  const filesTotal =
121
135
  window === undefined
122
136
  ? RESTORE_FILES_TOTAL_TOKENS
@@ -124,14 +138,15 @@ export function renderRestoredContext(
124
138
 
125
139
  const sections: string[] = []
126
140
 
127
- let skillBudget = skillsTotal
128
- const skills = [...(state.restorable?.skills ?? [])].reverse()
129
- for (const skill of skills) {
130
- if (sections.length >= RESTORE_MAX_SKILLS || skillBudget <= 0) break
131
- const body = fit(skill.body, Math.min(RESTORE_SKILL_TOKENS, skillBudget))
132
- skillBudget -= Math.ceil(body.length / CHARS_PER_TOKEN)
133
- sections.push(`### Skill: ${skill.name}\n\n${body}`)
134
- }
141
+ const toShow = (state.restorable?.skills ?? []).filter(
142
+ (skill) => !options.wholeSkills?.has(skill.name),
143
+ )
144
+ const skills = renderLoadedSkills(
145
+ toShow,
146
+ window === undefined ? {} : { contextWindowTokens: window },
147
+ )
148
+ options.onSkills?.(skills)
149
+ if (skills.text) sections.push(`### Skills loaded earlier\n\n${skills.text}`)
135
150
 
136
151
  let fileBudget = filesTotal
137
152
  let files = 0
@@ -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
 
@@ -64,6 +64,7 @@ export type SessionStreamEventType =
64
64
  | 'compaction.completed'
65
65
  /** Oversized tool results were emptied instead of the history summarized. */
66
66
  | 'compaction.tool_results_cleared'
67
+ | 'compaction.skills_carried'
67
68
  | 'memory.consolidated'
68
69
  | 'memory.recalled'
69
70
  | 'background_job.exited'
@@ -12,25 +12,51 @@ export interface PhaseRecord {
12
12
  readonly declared: boolean
13
13
  }
14
14
 
15
+ /**
16
+ * The entry of `entries` a title belongs to: one with exactly that title
17
+ * first; only when there is none, the first one whose title matches ignoring
18
+ * case. Two entries that differ only by case therefore stay two, because each
19
+ * matches itself exactly.
20
+ */
21
+ function matchTitle<T extends { readonly title: string }>(
22
+ entries: Iterable<T>,
23
+ title: string,
24
+ ): T | undefined {
25
+ const lower = title.toLowerCase()
26
+ let loose: T | undefined
27
+ for (const entry of entries) {
28
+ if (entry.title === title) return entry
29
+ if (loose === undefined && entry.title.toLowerCase() === lower) loose = entry
30
+ }
31
+ return loose
32
+ }
33
+
15
34
  /**
16
35
  * Phase titles of one run. A title is the only grouping key: the same title
17
- * is the same group. Indexes are 1-based first-seen order, declared titles
36
+ * is the same group. A title matches a registered one exactly first; only when
37
+ * none matches exactly does it match one ignoring case (the first registered
38
+ * wins, and its spelling is kept). Two declared titles that differ only by
39
+ * case stay two phases. Indexes are 1-based first-seen order, declared titles
18
40
  * first. `register` reports whether the title was announced just now.
19
41
  */
20
42
  export class PhaseRegistry {
21
- private readonly byTitle = new Map<string, PhaseRecord>()
43
+ private readonly records: PhaseRecord[] = []
22
44
 
23
45
  register(rawTitle: string, declared = false): { phase: PhaseRecord; isNew: boolean } {
24
46
  const title = normalizePhaseTitle(rawTitle)
25
- const existing = this.byTitle.get(title)
47
+ // A declared title is matched exactly only, so two declared titles that
48
+ // differ only by case each get their own phase.
49
+ const existing = declared
50
+ ? this.records.find((record) => record.title === title)
51
+ : matchTitle(this.records, title)
26
52
  if (existing) return { phase: existing, isNew: false }
27
- const phase = { index: this.byTitle.size + 1, title, declared }
28
- this.byTitle.set(title, phase)
53
+ const phase = { index: this.records.length + 1, title, declared }
54
+ this.records.push(phase)
29
55
  return { phase, isNew: true }
30
56
  }
31
57
 
32
58
  all(): PhaseRecord[] {
33
- return [...this.byTitle.values()]
59
+ return [...this.records]
34
60
  }
35
61
  }
36
62
 
@@ -57,7 +83,7 @@ export function summarizeWorkflowPhases(
57
83
  phases: readonly PhaseRecord[],
58
84
  agents: readonly PhaseAgentView[],
59
85
  ): WorkflowPhaseSummary[] {
60
- const detail = new Map(declared.map((p) => [normalizePhaseTitle(p.title), p.detail]))
86
+ const named = declared.map((p) => ({ title: normalizePhaseTitle(p.title), detail: p.detail }))
61
87
  const summarize = (
62
88
  index: number,
63
89
  title: string,
@@ -82,7 +108,7 @@ export function summarizeWorkflowPhases(
82
108
  p.index,
83
109
  p.title,
84
110
  agents.filter((a) => a.phaseIndex === p.index),
85
- detail.get(p.title),
111
+ matchTitle(named, p.title)?.detail,
86
112
  ),
87
113
  )
88
114
  const ungrouped = agents.filter((a) => a.phaseIndex === undefined)