@hmharness/kernel 0.6.7 → 0.6.8
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/dist/mcp.js +33 -1
- package/dist/provider.d.ts +10 -2
- package/dist/provider.js +45 -10
- package/package.json +1 -1
package/dist/mcp.js
CHANGED
|
@@ -14,6 +14,11 @@ function nextId() {
|
|
|
14
14
|
nextId.n = (nextId.n ?? 0) + 1;
|
|
15
15
|
return nextId.n;
|
|
16
16
|
}
|
|
17
|
+
/** Transport-level failure worth one retry (and worth explaining in the tool
|
|
18
|
+
* result instead of leaking a bare "TypeError: fetch failed"). */
|
|
19
|
+
function isTransientTransport(msg) {
|
|
20
|
+
return /fetch failed|terminated|ECONN|EAI_AGAIN|ENOTFOUND|ETIMEDOUT|timeout|abort|socket|premature close|UND_ERR/i.test(msg);
|
|
21
|
+
}
|
|
17
22
|
(function (nextId) {
|
|
18
23
|
})(nextId || (nextId = {}));
|
|
19
24
|
export class McpClient {
|
|
@@ -161,7 +166,25 @@ export class McpClient {
|
|
|
161
166
|
const ctrl = new AbortController();
|
|
162
167
|
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
163
168
|
try {
|
|
164
|
-
|
|
169
|
+
// one retry for transient transport failures: a dead/restarting MCP
|
|
170
|
+
// server used to surface a bare "TypeError: fetch failed" straight to
|
|
171
|
+
// the model as the tool result
|
|
172
|
+
let res;
|
|
173
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
174
|
+
try {
|
|
175
|
+
res = await fetch(cfg.url, { method: 'POST', headers, body: JSON.stringify(payload), signal: ctrl.signal });
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
if (attempt === 0 && isTransientTransport(String(err))) {
|
|
180
|
+
await new Promise((r) => setTimeout(r, 800));
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
throw err;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (!res)
|
|
187
|
+
throw new Error('no response');
|
|
165
188
|
const sid = res.headers.get('mcp-session-id');
|
|
166
189
|
if (sid)
|
|
167
190
|
this.sessionId = sid;
|
|
@@ -177,6 +200,15 @@ export class McpClient {
|
|
|
177
200
|
}
|
|
178
201
|
return body;
|
|
179
202
|
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
const msg = String(err);
|
|
205
|
+
if (/^mcp\//.test(msg))
|
|
206
|
+
throw err; // already annotated (HTTP status, parse)
|
|
207
|
+
const hint = isTransientTransport(msg)
|
|
208
|
+
? ` - the MCP server at ${cfg.url} is unreachable or cut the connection`
|
|
209
|
+
: '';
|
|
210
|
+
throw new Error(`mcp/${this.serverName}: request failed (${msg})${hint}`);
|
|
211
|
+
}
|
|
180
212
|
finally {
|
|
181
213
|
clearTimeout(timer);
|
|
182
214
|
}
|
package/dist/provider.d.ts
CHANGED
|
@@ -14,7 +14,7 @@ export interface ChatResponse {
|
|
|
14
14
|
completion_tokens?: number;
|
|
15
15
|
};
|
|
16
16
|
}
|
|
17
|
-
export type DeltaKind = 'text' | 'reasoning';
|
|
17
|
+
export type DeltaKind = 'text' | 'reasoning' | 'reset';
|
|
18
18
|
/** Parse a Retry-After header into a delay in ms (MDN: exactly two legal
|
|
19
19
|
* forms - delta-seconds or HTTP-date), clamped to [0, 120s]. Returns 0 when
|
|
20
20
|
* absent/illegal (caller falls back to exponential backoff). Deliberately
|
|
@@ -24,8 +24,16 @@ export type DeltaKind = 'text' | 'reasoning';
|
|
|
24
24
|
export declare function parseRetryAfterMs(raw: string | null | undefined, maxMs?: number): number;
|
|
25
25
|
export interface ChatOptions {
|
|
26
26
|
timeoutMs?: number;
|
|
27
|
-
/** Streaming callback; presence switches the request to stream:true.
|
|
27
|
+
/** Streaming callback; presence switches the request to stream:true.
|
|
28
|
+
* kind 'reset' carries an empty chunk and means: the previous attempt died
|
|
29
|
+
* mid-stream and is being retried - discard what you streamed for it. */
|
|
28
30
|
onDelta?(kind: DeltaKind, chunk: string): void;
|
|
31
|
+
/** Retry policy for transient failures. Defaults: 6 attempts, 3s base with
|
|
32
|
+
* exponential backoff and jitter, capped at 60s per wait. */
|
|
33
|
+
retry?: {
|
|
34
|
+
attempts?: number;
|
|
35
|
+
baseMs?: number;
|
|
36
|
+
};
|
|
29
37
|
}
|
|
30
38
|
export declare function chat(cfg: ProviderConfig, messages: ChatMessage[], tools?: unknown[], opts?: ChatOptions): Promise<ChatResponse>;
|
|
31
39
|
/** Accept bases with any /vN suffix (v1 OpenAI convention, v4 zhipu coding
|
package/dist/provider.js
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
/** Transient failures worth retrying. `terminated` is undici's TypeError for
|
|
2
|
+
* a socket cut mid-body (gateway dropped a long stream, proxy reset, server
|
|
3
|
+
* restart) - the case that used to kill the whole task with a raw
|
|
4
|
+
* `TypeError: terminated` because it matched none of the old patterns. */
|
|
5
|
+
const TRANSIENT_RE = /abort|terminated|fetch failed|ECONN|EAI_AGAIN|ENOTFOUND|ETIMEDOUT|timeout|socket hang up|other side closed|premature close|UND_ERR|EPIPE|no response body/i;
|
|
6
|
+
/** Human hint for the failure classes users actually hit. */
|
|
7
|
+
function transientHint(msg) {
|
|
8
|
+
if (/terminated|socket hang up|other side closed|premature close/i.test(msg)) {
|
|
9
|
+
return ' - the connection was cut mid-response (gateway dropped a long stream or a proxy reset it)';
|
|
10
|
+
}
|
|
11
|
+
if (/abort|timeout/i.test(msg))
|
|
12
|
+
return ' - the provider stopped sending data (idle/timeout)';
|
|
13
|
+
if (/fetch failed|ECONN|EAI_AGAIN|ENOTFOUND|UND_ERR/i.test(msg))
|
|
14
|
+
return ' - the provider endpoint was unreachable (network/DNS)';
|
|
15
|
+
return '';
|
|
16
|
+
}
|
|
1
17
|
/** Parse a Retry-After header into a delay in ms (MDN: exactly two legal
|
|
2
18
|
* forms - delta-seconds or HTTP-date), clamped to [0, 120s]. Returns 0 when
|
|
3
19
|
* absent/illegal (caller falls back to exponential backoff). Deliberately
|
|
@@ -33,13 +49,21 @@ export async function chat(cfg, messages, tools, opts = {}) {
|
|
|
33
49
|
let authScheme = cfg.authHeader ?? 'bearer';
|
|
34
50
|
let lastError = '';
|
|
35
51
|
// Resilient retry: exponential backoff with jitter, up to 6 attempts
|
|
36
|
-
// (Codex-style fire-and-forget: network hiccups, 429s,
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
const
|
|
52
|
+
// (Codex-style fire-and-forget: network hiccups, 429s, provider restarts,
|
|
53
|
+
// and mid-stream socket cuts should NEVER kill a long-running task).
|
|
54
|
+
const MAX_RETRIES = opts.retry?.attempts ?? 6;
|
|
55
|
+
const BASE_MS = opts.retry?.baseMs ?? 3000;
|
|
40
56
|
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
41
57
|
const ctrl = new AbortController();
|
|
42
58
|
const timer = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? cfg.timeoutMs ?? 120_000);
|
|
59
|
+
// Did this attempt stream anything to the UI before dying? A mid-stream
|
|
60
|
+
// cut leaves partial deltas on screen; retrying would duplicate them, so
|
|
61
|
+
// clients are told to drop the partial block first (DeltaKind 'reset').
|
|
62
|
+
let emitted = false;
|
|
63
|
+
const emit = streaming
|
|
64
|
+
? (kind, chunk) => { if (kind !== 'reset')
|
|
65
|
+
emitted = true; opts.onDelta(kind, chunk); }
|
|
66
|
+
: undefined;
|
|
43
67
|
try {
|
|
44
68
|
const res = await fetch(endpoint(cfg.baseUrl), {
|
|
45
69
|
method: 'POST',
|
|
@@ -72,7 +96,7 @@ export async function chat(cfg, messages, tools, opts = {}) {
|
|
|
72
96
|
}
|
|
73
97
|
if (streaming) {
|
|
74
98
|
clearTimeout(timer);
|
|
75
|
-
return await consumeStream(res,
|
|
99
|
+
return await consumeStream(res, emit);
|
|
76
100
|
}
|
|
77
101
|
const data = (await res.json());
|
|
78
102
|
const choice = data.choices?.[0];
|
|
@@ -89,10 +113,14 @@ export async function chat(cfg, messages, tools, opts = {}) {
|
|
|
89
113
|
}
|
|
90
114
|
catch (err) {
|
|
91
115
|
lastError = String(err);
|
|
92
|
-
// transient (network, timeout, connection reset): retry
|
|
93
|
-
if (
|
|
94
|
-
|
|
95
|
-
|
|
116
|
+
// transient (network, timeout, connection reset, mid-stream cut): retry
|
|
117
|
+
if (TRANSIENT_RE.test(lastError)) {
|
|
118
|
+
// the attempt already painted partial text: tell clients to drop it so
|
|
119
|
+
// the retry does not append a second copy of the same answer
|
|
120
|
+
if (emitted)
|
|
121
|
+
opts.onDelta?.('reset', '');
|
|
122
|
+
const delay = Math.min(60_000, BASE_MS * Math.pow(2, attempt));
|
|
123
|
+
await sleep(delay + Math.random() * Math.min(2000, BASE_MS));
|
|
96
124
|
continue;
|
|
97
125
|
}
|
|
98
126
|
throw err; // permanent error (bad JSON, logic error): don't retry
|
|
@@ -101,10 +129,12 @@ export async function chat(cfg, messages, tools, opts = {}) {
|
|
|
101
129
|
clearTimeout(timer);
|
|
102
130
|
}
|
|
103
131
|
}
|
|
104
|
-
throw new Error(`provider: failed after ${MAX_RETRIES}
|
|
132
|
+
throw new Error(`provider: failed after ${MAX_RETRIES} attempts (${cfg.baseUrl}): ${lastError}${transientHint(lastError)}`);
|
|
105
133
|
}
|
|
106
134
|
/** Assemble a ChatResponse from an SSE stream, emitting deltas as they land. */
|
|
107
135
|
async function consumeStream(res, onDelta) {
|
|
136
|
+
if (!res.body)
|
|
137
|
+
throw new TypeError('provider: no response body (streaming requested)');
|
|
108
138
|
const reader = res.body.getReader();
|
|
109
139
|
const decoder = new TextDecoder();
|
|
110
140
|
let buf = '';
|
|
@@ -184,6 +214,11 @@ async function consumeStream(res, onDelta) {
|
|
|
184
214
|
}
|
|
185
215
|
finally {
|
|
186
216
|
clearTimeout(idleTimer);
|
|
217
|
+
// Cancel the body rather than releaseLock(): when the idle guard won the
|
|
218
|
+
// race, reader.read() is still outstanding and releaseLock() throws
|
|
219
|
+
// "Cannot release a readable stream reader..." - which would REPLACE the
|
|
220
|
+
// real error (a mid-stream cut / idle timeout) with a meaningless one.
|
|
221
|
+
await reader.cancel().catch(() => undefined);
|
|
187
222
|
reader.releaseLock();
|
|
188
223
|
}
|
|
189
224
|
const ordered = [...calls.entries()].sort((a, b) => a[0] - b[0]).map(([, c]) => c);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hmharness/kernel",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.8",
|
|
4
4
|
"description": "hmharness kernel: tool registry, provider adapters, the agent loop, session log, config. Zero runtime dependencies (Node >=22 native fetch).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|