@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 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
- const res = await fetch(cfg.url, { method: 'POST', headers, body: JSON.stringify(payload), signal: ctrl.signal });
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
  }
@@ -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, and provider
37
- // restarts should NEVER kill a long-running task). Retry-After header
38
- // from 429s is honoured when present.
39
- const MAX_RETRIES = 6;
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, opts.onDelta);
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 with backoff
93
- if (/abort|fetch failed|ECONN|EAI_AGAIN|ENOTFOUND|timeout|socket hang up/i.test(lastError)) {
94
- const delay = Math.min(60_000, 3000 * Math.pow(2, attempt));
95
- await sleep(delay + Math.random() * 2000);
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} retries (${cfg.baseUrl}): ${lastError}`);
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.7",
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",