@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.
- package/CHANGELOG.md +24 -0
- package/dist/bridge/a2a/mapper.d.ts.map +1 -1
- package/dist/bridge/a2a/mapper.js +1 -0
- package/dist/bridge/a2a/mapper.js.map +1 -1
- package/dist/bridge/sse/mapper.d.ts.map +1 -1
- package/dist/bridge/sse/mapper.js +9 -0
- package/dist/bridge/sse/mapper.js.map +1 -1
- package/dist/compaction/loaded-skills.d.ts +77 -0
- package/dist/compaction/loaded-skills.d.ts.map +1 -0
- package/dist/compaction/loaded-skills.js +203 -0
- package/dist/compaction/loaded-skills.js.map +1 -0
- package/dist/compaction/manager.d.ts +10 -0
- package/dist/compaction/manager.d.ts.map +1 -1
- package/dist/compaction/manager.js +20 -0
- package/dist/compaction/manager.js.map +1 -1
- package/dist/compaction/manual.d.ts.map +1 -1
- package/dist/compaction/manual.js +17 -3
- package/dist/compaction/manual.js.map +1 -1
- package/dist/compaction/restore.d.ts +5 -0
- package/dist/compaction/restore.d.ts.map +1 -1
- package/dist/compaction/restore.js +18 -18
- package/dist/compaction/restore.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/contracts/session/api.d.ts +1 -1
- package/dist/contracts/session/api.d.ts.map +1 -1
- package/dist/execution/workflow/phases.d.ts +5 -2
- package/dist/execution/workflow/phases.d.ts.map +1 -1
- package/dist/execution/workflow/phases.js +32 -8
- package/dist/execution/workflow/phases.js.map +1 -1
- package/dist/runtime/query/iteration/phases/compaction.d.ts +1 -1
- package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/compaction.js +76 -11
- package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
- package/dist/tools/builtins/skill.d.ts.map +1 -1
- package/dist/tools/builtins/skill.js +5 -0
- package/dist/tools/builtins/skill.js.map +1 -1
- package/dist/turn/reporter.d.ts.map +1 -1
- package/dist/turn/reporter.js +9 -0
- package/dist/turn/reporter.js.map +1 -1
- package/dist/types/connector/mcp.d.ts +19 -0
- package/dist/types/connector/mcp.d.ts.map +1 -1
- package/dist/types/session/events.d.ts +26 -3
- package/dist/types/session/events.d.ts.map +1 -1
- package/dist/types/session/events.js +4 -1
- package/dist/types/session/events.js.map +1 -1
- package/dist/types/session/records.d.ts.map +1 -1
- package/dist/types/session/records.js +1 -0
- package/dist/types/session/records.js.map +1 -1
- package/package.json +1 -1
- package/src/bridge/a2a/mapper.ts +1 -0
- package/src/bridge/sse/mapper.ts +10 -0
- package/src/compaction/loaded-skills.ts +245 -0
- package/src/compaction/manager.ts +24 -0
- package/src/compaction/manual.ts +20 -2
- package/src/compaction/restore.ts +33 -18
- 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/contracts/session/api.ts +1 -0
- package/src/execution/workflow/phases.ts +34 -8
- package/src/runtime/query/iteration/phases/compaction.ts +102 -16
- package/src/tools/builtins/skill.ts +5 -0
- package/src/turn/reporter.ts +10 -0
- package/src/types/connector/mcp.ts +19 -0
- package/src/types/session/events.ts +27 -2
- 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)
|
package/src/compaction/manual.ts
CHANGED
|
@@ -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(
|
|
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 =
|
|
30
|
+
export const RESTORE_MAX_SKILLS = CARRY_MAX_SKILLS
|
|
20
31
|
/** Tokens of one restored skill. */
|
|
21
|
-
export const RESTORE_SKILL_TOKENS =
|
|
32
|
+
export const RESTORE_SKILL_TOKENS = CARRY_SKILL_TOKENS
|
|
22
33
|
/** Tokens of all restored skills together. */
|
|
23
|
-
export const RESTORE_SKILLS_TOTAL_TOKENS =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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,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
|
|
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
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.
|
|
28
|
-
this.
|
|
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.
|
|
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
|
|
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
|
-
|
|
111
|
+
matchTitle(named, p.title)?.detail,
|
|
86
112
|
),
|
|
87
113
|
)
|
|
88
114
|
const ungrouped = agents.filter((a) => a.phaseIndex === undefined)
|