@salesforce/sfdx-agent-harness-mastra 0.60.0 → 0.62.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 CHANGED
@@ -3,6 +3,16 @@
3
3
  All notable changes to `@salesforce/sfdx-agent-harness-mastra` are documented in this file.
4
4
  Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
+ ## [0.62.0] - 2026-09-02
7
+
8
+ ### Fixes
9
+ - **harness-mastra**: classify empty/opaque gateway stream failures @W-24026850@ @W-24027340@ ([#782](https://github.com/forcedotcom/agentic-dx/pull/782))
10
+
11
+ ## [0.61.0] - 2026-09-01
12
+
13
+ ### Fixes
14
+ - **harness-mastra**: forward abortSignal into provider gateway fetch @W-23988781@ ([#779](https://github.com/forcedotcom/agentic-dx/pull/779))
15
+
6
16
  ## [0.60.0] - 2026-09-01
7
17
 
8
18
  ### Features
@@ -2,7 +2,7 @@
2
2
  * Copyright 2026, Salesforce, Inc. All rights reserved.
3
3
  * See LICENSE.txt for license terms.
4
4
  */
5
- import { isNetworkStreamError } from './stream-errors.js';
5
+ import { isClassifiedStreamError } from './stream-errors.js';
6
6
  /**
7
7
  * Adapts Mastra `ChunkType` stream events to our {@link ChatEvent} discriminated union.
8
8
  *
@@ -156,7 +156,7 @@ export class MastraEventAdapter {
156
156
  return {
157
157
  type: 'error',
158
158
  error: rawError instanceof Error ? rawError : new Error(String(rawError)),
159
- ...(isNetworkStreamError(rawError) ? { code: rawError.code } : {}),
159
+ ...(isClassifiedStreamError(rawError) ? { code: rawError.code } : {}),
160
160
  };
161
161
  }
162
162
  case 'tripwire':
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { AnthropicBedrock } from '@anthropic-ai/bedrock-sdk';
6
6
  import Anthropic from '@anthropic-ai/sdk';
7
- import { NetworkStreamError, isStreamTransportError } from './stream-errors.js';
7
+ import { EmptyStreamError, NetworkStreamError, isStreamTransportError, recognizeBedrockValidationError, } from './stream-errors.js';
8
8
  import { TextSpanLifecycle, createGatewayFetch, isEmptyUsage, isParameterExcluded, mapAnthropicFinishReason, mapAnthropicUsage, normalizeToolArgsString, toolResultOutputToString, } from './v3-stream-mapping.js';
9
9
  /**
10
10
  * Builds the in-process `LanguageModelV3` impl for the Salesforce LLM Gateway's
@@ -170,7 +170,17 @@ async function buildAnthropicRequest(ctx, options) {
170
170
  }
171
171
  async function doGenerate(ctx, options) {
172
172
  const { client, commonParams } = await buildAnthropicRequest(ctx, options);
173
- const message = await client.messages.create({ ...commonParams, stream: false });
173
+ // Forward the turn's AbortSignal into the SDK's RequestOptions so firing the
174
+ // controller (POST /cancel, idle timeout, shutdown abortAll) actually aborts
175
+ // the outbound gateway fetch, not just harness bookkeeping (W-23988781).
176
+ //
177
+ // doGenerate is intentionally NOT run through recognizeBedrockValidationError
178
+ // (unlike doStream's create() seam): the only non-streaming caller in this
179
+ // harness is compactThread's summarize(), whose rejection is already wrapped
180
+ // as COMPACTION_FAILED. Error classification here targets the streaming turn
181
+ // path (8d) — a raw create() rejection propagating uncoded is correct for the
182
+ // summarize-only path.
183
+ const message = await client.messages.create({ ...commonParams, stream: false }, { signal: options.abortSignal });
174
184
  const content = [];
175
185
  let sawToolCalls = false;
176
186
  for (const block of message.content) {
@@ -201,7 +211,39 @@ async function doGenerate(ctx, options) {
201
211
  }
202
212
  async function doStream(ctx, options) {
203
213
  const { client, commonParams } = await buildAnthropicRequest(ctx, options);
204
- const sdkStream = await client.messages.create({ ...commonParams, stream: true });
214
+ // See doGenerate: thread the AbortSignal into the SDK so a mid-stream abort
215
+ // tears down the live gateway socket (W-23988781).
216
+ let sdkStream;
217
+ try {
218
+ sdkStream = await client.messages.create({ ...commonParams, stream: true }, { signal: options.abortSignal });
219
+ }
220
+ catch (error) {
221
+ // A Bedrock `ValidationException` (notably a context-window overflow) is an
222
+ // HTTP 4xx that rejects `.create()` BEFORE the response stream is
223
+ // established — it never reaches the iterator catch below, so this is where
224
+ // that shape must be classified (8d). Confirmed on the orgfarm-e2e mastra
225
+ // run: an oversized-input failure surfaces as a rejected `.create()`, not a
226
+ // mid-stream frame. Yield it as an `error` chunk carrying the typed code
227
+ // rather than letting the raw rejection propagate uncoded. Every OTHER
228
+ // pre-stream rejection is rethrown unchanged to preserve the documented
229
+ // abort contract (an `APIUserAbortError` before the response propagates as a
230
+ // thrown rejection). We deliberately do NOT run the transport classifier
231
+ // here: `isStreamTransportError` matches the `AbortError` name, so
232
+ // classifying a pre-stream abort as `network-error` would break that
233
+ // contract. (A gateway that instead delivers the ValidationException as a
234
+ // 200 + eventstream error frame is still covered by the iterator catch.)
235
+ const validation = recognizeBedrockValidationError(error);
236
+ if (!validation)
237
+ throw error;
238
+ return {
239
+ stream: new ReadableStream({
240
+ start: (controller) => {
241
+ controller.enqueue({ type: 'error', error: validation });
242
+ controller.close();
243
+ },
244
+ }),
245
+ };
246
+ }
205
247
  const stream = new ReadableStream({
206
248
  start: async (controller) => {
207
249
  const textSpan = new TextSpanLifecycle();
@@ -209,6 +251,11 @@ async function doStream(ctx, options) {
209
251
  // sequence of input_json_delta strings on a single content block.
210
252
  const toolUseByIndex = new Map();
211
253
  let sawToolCalls = false;
254
+ // 8a false-success guard: did we see ≥1 recognized content / usage
255
+ // event? If the iterator exhausts cleanly having seen none, an
256
+ // intermediary closed the body without serving the request — surface a
257
+ // classified `error`, not a synthesized empty `finish`.
258
+ let sawContentOrUsage = false;
212
259
  // Cumulative usage tracked across `message_start` and `message_delta`.
213
260
  let mergedUsage;
214
261
  let finishRaw;
@@ -220,6 +267,7 @@ async function doStream(ctx, options) {
220
267
  switch (event.type) {
221
268
  case 'message_start': {
222
269
  mergedUsage = mergeUsage(mergedUsage, event.message.usage);
270
+ sawContentOrUsage = true;
223
271
  // `stop_reason` is null on message_start in streaming mode.
224
272
  break;
225
273
  }
@@ -248,6 +296,7 @@ async function doStream(ctx, options) {
248
296
  const delta = event.delta;
249
297
  if (delta.type === 'text_delta') {
250
298
  textSpan.delta(controller, delta.text);
299
+ sawContentOrUsage = true;
251
300
  }
252
301
  else if (delta.type === 'input_json_delta') {
253
302
  const tu = toolUseByIndex.get(event.index);
@@ -263,6 +312,7 @@ async function doStream(ctx, options) {
263
312
  const tu = toolUseByIndex.get(event.index);
264
313
  if (tu) {
265
314
  sawToolCalls = true;
315
+ sawContentOrUsage = true;
266
316
  controller.enqueue({
267
317
  type: 'tool-call',
268
318
  toolCallId: tu.id,
@@ -278,6 +328,7 @@ async function doStream(ctx, options) {
278
328
  case 'message_delta': {
279
329
  mergedUsage = mergeUsage(mergedUsage, event.usage);
280
330
  finishRaw = event.delta.stop_reason;
331
+ sawContentOrUsage = true;
281
332
  break;
282
333
  }
283
334
  case 'message_stop': {
@@ -291,10 +342,25 @@ async function doStream(ctx, options) {
291
342
  }
292
343
  }
293
344
  }
294
- // Stream ended without an explicit `message_stop` — close out the span
295
- // and synthesize a terminal `finish` so V3 consumers see exactly one.
345
+ // Stream ended without an explicit `message_stop`. If we saw at least
346
+ // one recognized content / usage event, close out the span and
347
+ // synthesize a terminal `finish` so V3 consumers see exactly one. If
348
+ // we saw NONE — a clean-but-empty body — surface a classified `error`
349
+ // instead of a false-success empty `finish` (8a). A cancelled turn is
350
+ // exempt: an aborted stream ends cleanly here too (the SDK `Stream`
351
+ // swallows the abort), and its terminal event is the coordinator's
352
+ // teardown, not an empty-stream error.
296
353
  if (!terminalEmitted) {
297
354
  textSpan.closeIfOpen(controller);
355
+ if (!sawContentOrUsage && !options.abortSignal?.aborted) {
356
+ controller.enqueue({
357
+ type: 'error',
358
+ error: new EmptyStreamError('LLM Gateway pass-through (Anthropic) stream closed without any content, usage, or terminal event'),
359
+ });
360
+ terminalEmitted = true;
361
+ controller.close();
362
+ return;
363
+ }
298
364
  const finishReason = mapAnthropicFinishReason(finishRaw, sawToolCalls);
299
365
  const usage = finalizeUsage(mergedUsage, ctx, finishReason.raw);
300
366
  controller.enqueue({ type: 'finish', finishReason, usage });
@@ -311,10 +377,14 @@ async function doStream(ctx, options) {
311
377
  controller.close();
312
378
  return;
313
379
  }
314
- controller.enqueue({
315
- type: 'error',
316
- error: isStreamTransportError(error) ? new NetworkStreamError(error) : error,
317
- });
380
+ // Classify before forwarding: a transport truncation → NetworkStreamError
381
+ // (`network-error`); a Bedrock ValidationException → ValidationStreamError
382
+ // (`context-window-exceeded` / `validation-error`, 8d). An unrecognized
383
+ // provider error is forwarded raw (uncoded), same as before.
384
+ const classified = isStreamTransportError(error)
385
+ ? new NetworkStreamError(error)
386
+ : recognizeBedrockValidationError(error);
387
+ controller.enqueue({ type: 'error', error: classified ?? error });
318
388
  controller.close();
319
389
  }
320
390
  },
@@ -3,6 +3,7 @@
3
3
  * See LICENSE.txt for license terms.
4
4
  */
5
5
  import OpenAI from 'openai';
6
+ import { EmptyStreamError } from './stream-errors.js';
6
7
  import { TextSpanLifecycle, createGatewayFetch, isEmptyUsage, isParameterExcluded, mapOpenAIUsage, normalizeToolArgsString, toolResultOutputToString, } from './v3-stream-mapping.js';
7
8
  /**
8
9
  * Builds the in-process `LanguageModelV3` impl that drives the `openai` SDK's
@@ -78,7 +79,10 @@ async function buildRequest(ctx, options) {
78
79
  async function doGenerate(ctx, options) {
79
80
  const { client, commonParams } = await buildRequest(ctx, options);
80
81
  const params = { ...commonParams };
81
- const response = await client.chat.completions.create(params);
82
+ // Forward the turn's AbortSignal into the SDK's RequestOptions so firing the
83
+ // controller (POST /cancel, idle timeout, shutdown abortAll) actually aborts
84
+ // the outbound gateway fetch, not just harness bookkeeping (W-23988781).
85
+ const response = await client.chat.completions.create(params, { signal: options.abortSignal });
82
86
  const content = [];
83
87
  const choice = response.choices[0];
84
88
  let sawToolCalls = false;
@@ -120,7 +124,9 @@ async function doStream(ctx, options) {
120
124
  // to `undefined` `LanguageModelV3Usage` per the missing-usage contract.
121
125
  stream_options: { include_usage: true },
122
126
  };
123
- const sdkStream = await client.chat.completions.create(params);
127
+ // See doGenerate: thread the AbortSignal into the SDK so a mid-stream abort
128
+ // tears down the live gateway socket (W-23988781).
129
+ const sdkStream = await client.chat.completions.create(params, { signal: options.abortSignal });
124
130
  const stream = new ReadableStream({
125
131
  start: async (controller) => {
126
132
  const textSpan = new TextSpanLifecycle();
@@ -134,6 +140,11 @@ async function doStream(ctx, options) {
134
140
  let finishRaw;
135
141
  let usage;
136
142
  let terminalEmitted = false;
143
+ // 8a false-success guard — see the Anthropic builder. Set on any content
144
+ // text, tool call, usage chunk, or terminal `finish_reason`; a stream that
145
+ // produced none of those surfaces a classified `error`, not an empty
146
+ // `finish`.
147
+ let sawContentOrUsage = false;
137
148
  try {
138
149
  for await (const chunk of sdkStream) {
139
150
  // OpenAI-compatible servers emit a final usage-only chunk when
@@ -142,6 +153,7 @@ async function doStream(ctx, options) {
142
153
  // populated `usage` field; treat it as a usage-only signal.
143
154
  if (chunk.usage) {
144
155
  usage = mapChatCompletionUsage(chunk.usage);
156
+ sawContentOrUsage = true;
145
157
  }
146
158
  const choice = chunk.choices[0];
147
159
  if (!choice)
@@ -149,6 +161,7 @@ async function doStream(ctx, options) {
149
161
  const delta = choice.delta;
150
162
  if (delta.content) {
151
163
  textSpan.delta(controller, delta.content);
164
+ sawContentOrUsage = true;
152
165
  }
153
166
  if (delta.tool_calls) {
154
167
  // V3 ordering: any open text span must close BEFORE tool-call parts
@@ -159,6 +172,7 @@ async function doStream(ctx, options) {
159
172
  // and pass-through-openai.ts both rotate eagerly at the same moment).
160
173
  // `rotate` is a no-op when no text span is open.
161
174
  textSpan.rotate(controller);
175
+ sawContentOrUsage = true;
162
176
  for (const tc of delta.tool_calls) {
163
177
  const idx = tc.index;
164
178
  let acc = toolCallsByIndex.get(idx);
@@ -184,6 +198,7 @@ async function doStream(ctx, options) {
184
198
  }
185
199
  if (choice.finish_reason !== null && choice.finish_reason !== undefined) {
186
200
  finishRaw = choice.finish_reason;
201
+ sawContentOrUsage = true;
187
202
  // Flush any buffered tool calls. Chat Completions doesn't send a
188
203
  // discrete "tool_call done" event — they're complete once the
189
204
  // chunk's `finish_reason` arrives.
@@ -221,6 +236,19 @@ async function doStream(ctx, options) {
221
236
  }
222
237
  }
223
238
  textSpan.closeIfOpen(controller);
239
+ // 8a: a clean-but-empty stream (no content, tool call, usage, or
240
+ // terminal `finish_reason`) is a false success — surface a classified
241
+ // `error` instead of the synthesized `finish`. A cancelled turn is
242
+ // exempt (its terminal event is the coordinator's teardown).
243
+ if (!sawContentOrUsage && !options.abortSignal?.aborted) {
244
+ controller.enqueue({
245
+ type: 'error',
246
+ error: new EmptyStreamError('OpenAI-compatible chat-completions stream closed without any content, usage, or terminal event'),
247
+ });
248
+ terminalEmitted = true;
249
+ controller.close();
250
+ return;
251
+ }
224
252
  const finishReason = mapChatCompletionFinishReason(finishRaw ?? null, sawToolCalls);
225
253
  const finalUsage = usage ?? mapChatCompletionUsage(undefined);
226
254
  if (isEmptyUsage(finalUsage)) {
@@ -3,6 +3,7 @@
3
3
  * See LICENSE.txt for license terms.
4
4
  */
5
5
  import OpenAI from 'openai';
6
+ import { EmptyStreamError, GatewayResponseError } from './stream-errors.js';
6
7
  import { TextSpanLifecycle, createGatewayFetch, isEmptyUsage, isParameterExcluded, mapOpenAIFinishReason, mapOpenAIUsage, normalizeToolArgsString, toolResultOutputToString, } from './v3-stream-mapping.js';
7
8
  /**
8
9
  * Builds the in-process `LanguageModelV3` impl that drives the `openai` SDK's
@@ -76,7 +77,10 @@ async function buildOpenAIRequest(ctx, options) {
76
77
  async function doGenerate(ctx, options) {
77
78
  const { client, commonParams } = await buildOpenAIRequest(ctx, options);
78
79
  const params = { ...commonParams };
79
- const response = await client.responses.create(params);
80
+ // Forward the turn's AbortSignal into the SDK's RequestOptions so firing the
81
+ // controller (POST /cancel, idle timeout, shutdown abortAll) actually aborts
82
+ // the outbound gateway fetch, not just harness bookkeeping (W-23988781).
83
+ const response = await client.responses.create(params, { signal: options.abortSignal });
80
84
  const content = [];
81
85
  let sawToolCalls = false;
82
86
  if (response.output_text && response.output_text.length > 0) {
@@ -103,7 +107,9 @@ async function doGenerate(ctx, options) {
103
107
  async function doStream(ctx, options) {
104
108
  const { client, commonParams } = await buildOpenAIRequest(ctx, options);
105
109
  const params = { ...commonParams, stream: true };
106
- const sdkStream = await client.responses.create(params);
110
+ // See doGenerate: thread the AbortSignal into the SDK so a mid-stream abort
111
+ // tears down the live gateway socket (W-23988781).
112
+ const sdkStream = await client.responses.create(params, { signal: options.abortSignal });
107
113
  const stream = new ReadableStream({
108
114
  start: async (controller) => {
109
115
  const textSpan = new TextSpanLifecycle();
@@ -113,11 +119,19 @@ async function doStream(ctx, options) {
113
119
  const toolCallById = new Map();
114
120
  let sawToolCalls = false;
115
121
  let terminalEmitted = false;
122
+ // 8a false-success guard — see the Anthropic builder. The Responses
123
+ // wire carries usage only on the terminal `response.completed` /
124
+ // `response.incomplete` chunks (which return early), so here "content" is
125
+ // the whole signal: set on any text delta or completed tool call. A clean
126
+ // iterator exhaustion having seen none surfaces a classified `error`, not
127
+ // an empty `finish`.
128
+ let sawContent = false;
116
129
  try {
117
130
  for await (const event of sdkStream) {
118
131
  switch (event.type) {
119
132
  case 'response.output_text.delta': {
120
133
  textSpan.delta(controller, event.delta);
134
+ sawContent = true;
121
135
  break;
122
136
  }
123
137
  case 'response.output_item.added': {
@@ -146,6 +160,7 @@ async function doStream(ctx, options) {
146
160
  const tc = toolCallById.get(item.id ?? item.call_id);
147
161
  const finalArgs = item.arguments ?? tc?.argsBuffer ?? '';
148
162
  sawToolCalls = true;
163
+ sawContent = true;
149
164
  controller.enqueue({
150
165
  type: 'tool-call',
151
166
  toolCallId: item.call_id,
@@ -170,19 +185,33 @@ async function doStream(ctx, options) {
170
185
  controller.close();
171
186
  return;
172
187
  }
173
- case 'response.failed':
188
+ case 'response.failed': {
189
+ // A genuine failure — surface it as a classified `error`
190
+ // carrying the structured `response.error` (`code` +
191
+ // `message`) so the actionable reason (`rate_limit_exceeded`,
192
+ // `invalid_prompt`, …) reaches consumers instead of an opaque
193
+ // `finish('error')` that drops it (8c). Mirrors `case 'error'`
194
+ // below: an error chunk + close, no synthesized `finish`.
195
+ textSpan.closeIfOpen(controller);
196
+ const responseError = event.response.error;
197
+ controller.enqueue({
198
+ type: 'error',
199
+ error: new GatewayResponseError({
200
+ code: responseError?.code,
201
+ message: responseError?.message ?? 'OpenAI Responses API request failed',
202
+ }),
203
+ });
204
+ terminalEmitted = true;
205
+ controller.close();
206
+ return;
207
+ }
174
208
  case 'response.incomplete': {
209
+ // Truncation, not a failure — `incomplete_details.reason`
210
+ // (`max_output_tokens` / `content_filter`) maps to a normal
211
+ // `finish` with `unified='length'` / `'content-filter'`.
175
212
  textSpan.closeIfOpen(controller);
176
213
  const response = event.response;
177
- const rawStatus = response.status;
178
- // For `response.failed`, `status` is authoritative — drop any
179
- // `incomplete_details.reason` the upstream may have populated so
180
- // the mapper lands on its `status === 'failed'` arm and resolves
181
- // `unified='error'`. Forwarding the reason unconditionally would
182
- // demote `'error'` to `'length'` / `'content-filter'` on a
183
- // malformed-but-possible upstream shape.
184
- const incompleteReason = rawStatus === 'failed' ? undefined : response.incomplete_details?.reason;
185
- const finishReason = mapOpenAIFinishReason(rawStatus, sawToolCalls, incompleteReason);
214
+ const finishReason = mapOpenAIFinishReason(response.status, sawToolCalls, response.incomplete_details?.reason);
186
215
  const usage = finalizeUsage(response.usage, ctx, finishReason.raw);
187
216
  controller.enqueue({ type: 'finish', finishReason, usage });
188
217
  terminalEmitted = true;
@@ -190,9 +219,16 @@ async function doStream(ctx, options) {
190
219
  return;
191
220
  }
192
221
  case 'error': {
222
+ // Attach the upstream's structured `code` / `message` / `param`
223
+ // onto the emitted error part so consumers get an actionable
224
+ // reason instead of a bare message (8c).
193
225
  controller.enqueue({
194
226
  type: 'error',
195
- error: new Error(event.message ?? 'OpenAI Responses API error'),
227
+ error: new GatewayResponseError({
228
+ code: event.code,
229
+ message: event.message ?? 'OpenAI Responses API error',
230
+ param: event.param,
231
+ }),
196
232
  });
197
233
  terminalEmitted = true;
198
234
  controller.close();
@@ -203,10 +239,21 @@ async function doStream(ctx, options) {
203
239
  // ignored — they're not in the V3 ChatEvent surface today.
204
240
  }
205
241
  }
206
- // Stream ended without an explicit terminal event — synthesize one so
207
- // V3 consumers see exactly one `finish`.
242
+ // Stream ended without an explicit terminal event. Seen ≥1 recognized
243
+ // content event ⇒ synthesize a `finish` so V3 consumers see exactly one.
244
+ // Seen NONE (and not aborted) ⇒ a clean-but-empty body: surface a
245
+ // classified `error` rather than a false-success empty `finish` (8a).
208
246
  if (!terminalEmitted) {
209
247
  textSpan.closeIfOpen(controller);
248
+ if (!sawContent && !options.abortSignal?.aborted) {
249
+ controller.enqueue({
250
+ type: 'error',
251
+ error: new EmptyStreamError('LLM Gateway pass-through (OpenAI Responses) stream closed without any content, usage, or terminal event'),
252
+ });
253
+ terminalEmitted = true;
254
+ controller.close();
255
+ return;
256
+ }
210
257
  const finishReason = mapOpenAIFinishReason(undefined, sawToolCalls, undefined);
211
258
  const usage = finalizeUsage(undefined, ctx, finishReason.raw);
212
259
  controller.enqueue({ type: 'finish', finishReason, usage });
@@ -1,8 +1,74 @@
1
1
  export declare const NETWORK_ERROR_CODE: 'network-error';
2
- export declare class NetworkStreamError extends Error {
2
+ export declare const EMPTY_STREAM_ERROR_CODE: 'empty-stream';
3
+ export declare const CONTEXT_OVERFLOW_ERROR_CODE: 'context-window-exceeded';
4
+ export declare const VALIDATION_ERROR_CODE: 'validation-error';
5
+ export declare const GATEWAY_RESPONSE_ERROR_CODE: 'gateway-error';
6
+ /**
7
+ * Base for every error the pass-through builders classify onto a V3 `error`
8
+ * stream part. It carries a stable, machine-readable `code` that
9
+ * `MastraEventAdapter` copies onto the consumer-visible `error` ChatEvent (see
10
+ * the "mid-stream errors are yielded as `error` chunks" Critical Invariant), so
11
+ * consumers branch on `error.code` instead of string-matching the message. A
12
+ * raw provider error that isn't classified surfaces uncoded, exactly as before.
13
+ */
14
+ export declare abstract class ClassifiedStreamError extends Error {
15
+ abstract readonly code: string;
16
+ }
17
+ /** Transport-level truncation / socket blip (see {@link isStreamTransportError}); `network-error`. */
18
+ export declare class NetworkStreamError extends ClassifiedStreamError {
3
19
  readonly name = "NetworkStreamError";
4
20
  readonly code: "network-error";
5
21
  constructor(cause: Error);
6
22
  }
23
+ /** A stream that closed cleanly with no recognized content/usage/terminal event — the "false-success" case (8a); `empty-stream`. */
24
+ export declare class EmptyStreamError extends ClassifiedStreamError {
25
+ readonly name = "EmptyStreamError";
26
+ readonly code: "empty-stream";
27
+ constructor(message: string);
28
+ }
29
+ /**
30
+ * A structured provider-response error (8c). The provider's own `code` becomes
31
+ * {@link code} (falling back to `gateway-error`); `message` / optional `param`
32
+ * are preserved.
33
+ */
34
+ export declare class GatewayResponseError extends ClassifiedStreamError {
35
+ readonly name = "GatewayResponseError";
36
+ readonly code: string;
37
+ readonly param?: string;
38
+ constructor(opts: {
39
+ code?: string | null;
40
+ message: string;
41
+ param?: string | null;
42
+ });
43
+ }
44
+ /** A Bedrock `ValidationException` (8d); `context-window-exceeded` for an overflow shape, else `validation-error`. */
45
+ export declare class ValidationStreamError extends ClassifiedStreamError {
46
+ readonly name = "ValidationStreamError";
47
+ readonly code: string;
48
+ constructor(message: string, code: string);
49
+ }
50
+ /**
51
+ * Whether `error` (or any error in its `cause` chain) is a recognized
52
+ * transport-level truncation / socket fault. Walks the cause chain guarding
53
+ * against cycles. `AbortError` is included because an undici socket-abort under
54
+ * a hung read surfaces with that name (a user-initiated cancel never reaches
55
+ * here — the provider SDK `Stream` swallows it, see the abort Critical
56
+ * Invariant).
57
+ */
7
58
  export declare function isStreamTransportError(error: unknown): error is Error;
8
- export declare function isNetworkStreamError(error: unknown): error is NetworkStreamError;
59
+ /**
60
+ * Recognizes the Bedrock `ValidationException` shape (8d), walking the `cause`
61
+ * chain and duck-typing the several ways `@anthropic-ai/bedrock-sdk` /
62
+ * `@aws-sdk` surface it (an `APIError` with a parsed `.error` body, an AWS
63
+ * `__type` tag, or the name / message text). Returns a {@link ValidationStreamError}
64
+ * carrying the extracted message + a typed code (`context-window-exceeded` for a
65
+ * context-overflow shape, else `validation-error`), or `undefined` when the
66
+ * error isn't a `ValidationException`.
67
+ */
68
+ export declare function recognizeBedrockValidationError(error: unknown): ValidationStreamError | undefined;
69
+ /**
70
+ * Whether `error` is any harness-classified stream error (network, empty,
71
+ * gateway-response, validation, …). The event adapter uses this to copy the
72
+ * error's `code` onto the ChatEvent regardless of which classifier produced it.
73
+ */
74
+ export declare function isClassifiedStreamError(error: unknown): error is ClassifiedStreamError;
@@ -3,31 +3,171 @@
3
3
  * See LICENSE.txt for license terms.
4
4
  */
5
5
  export const NETWORK_ERROR_CODE = 'network-error';
6
- export class NetworkStreamError extends Error {
6
+ export const EMPTY_STREAM_ERROR_CODE = 'empty-stream';
7
+ export const CONTEXT_OVERFLOW_ERROR_CODE = 'context-window-exceeded';
8
+ export const VALIDATION_ERROR_CODE = 'validation-error';
9
+ export const GATEWAY_RESPONSE_ERROR_CODE = 'gateway-error';
10
+ /**
11
+ * Base for every error the pass-through builders classify onto a V3 `error`
12
+ * stream part. It carries a stable, machine-readable `code` that
13
+ * `MastraEventAdapter` copies onto the consumer-visible `error` ChatEvent (see
14
+ * the "mid-stream errors are yielded as `error` chunks" Critical Invariant), so
15
+ * consumers branch on `error.code` instead of string-matching the message. A
16
+ * raw provider error that isn't classified surfaces uncoded, exactly as before.
17
+ */
18
+ export class ClassifiedStreamError extends Error {
19
+ }
20
+ /** Transport-level truncation / socket blip (see {@link isStreamTransportError}); `network-error`. */
21
+ export class NetworkStreamError extends ClassifiedStreamError {
7
22
  name = 'NetworkStreamError';
8
23
  code = NETWORK_ERROR_CODE;
9
24
  constructor(cause) {
10
25
  super(cause.message, { cause });
11
26
  }
12
27
  }
28
+ /** A stream that closed cleanly with no recognized content/usage/terminal event — the "false-success" case (8a); `empty-stream`. */
29
+ export class EmptyStreamError extends ClassifiedStreamError {
30
+ name = 'EmptyStreamError';
31
+ code = EMPTY_STREAM_ERROR_CODE;
32
+ constructor(message) {
33
+ super(message);
34
+ }
35
+ }
36
+ /**
37
+ * A structured provider-response error (8c). The provider's own `code` becomes
38
+ * {@link code} (falling back to `gateway-error`); `message` / optional `param`
39
+ * are preserved.
40
+ */
41
+ export class GatewayResponseError extends ClassifiedStreamError {
42
+ name = 'GatewayResponseError';
43
+ code;
44
+ param;
45
+ constructor(opts) {
46
+ super(opts.message);
47
+ this.code = opts.code != null && opts.code.trim() !== '' ? opts.code : GATEWAY_RESPONSE_ERROR_CODE;
48
+ if (opts.param != null)
49
+ this.param = opts.param;
50
+ }
51
+ }
52
+ /** A Bedrock `ValidationException` (8d); `context-window-exceeded` for an overflow shape, else `validation-error`. */
53
+ export class ValidationStreamError extends ClassifiedStreamError {
54
+ name = 'ValidationStreamError';
55
+ code;
56
+ constructor(message, code) {
57
+ super(message);
58
+ this.code = code;
59
+ }
60
+ }
61
+ /**
62
+ * Node / undici transport-fault codes that mean "the socket dropped mid-flight,"
63
+ * not "the model failed." A stream failing with one of these is a transient blip
64
+ * a retry could recover — tagging it `network-error` is what lets a
65
+ * classification-keyed retry fire (8e).
66
+ */
67
+ const TRANSPORT_ERROR_CODES = new Set([
68
+ 'UND_ERR_SOCKET',
69
+ 'UND_ERR_CONNECT_TIMEOUT',
70
+ 'UND_ERR_HEADERS_TIMEOUT',
71
+ 'UND_ERR_BODY_TIMEOUT',
72
+ 'ECONNRESET',
73
+ 'ETIMEDOUT',
74
+ 'EPIPE',
75
+ ]);
76
+ /**
77
+ * Exact (punctuation-stripped, lower-cased) messages undici / the provider SDKs
78
+ * use for the same transport truncations. Kept as an exact-match allowlist —
79
+ * substring matching would mis-tag a provider error that merely *mentions* a
80
+ * transport phrase (guarded by a regression test).
81
+ */
82
+ const TRANSPORT_ERROR_MESSAGES = new Set([
83
+ 'terminated',
84
+ 'other side closed',
85
+ 'truncated event message received',
86
+ ]);
87
+ /**
88
+ * Whether `error` (or any error in its `cause` chain) is a recognized
89
+ * transport-level truncation / socket fault. Walks the cause chain guarding
90
+ * against cycles. `AbortError` is included because an undici socket-abort under
91
+ * a hung read surfaces with that name (a user-initiated cancel never reaches
92
+ * here — the provider SDK `Stream` swallows it, see the abort Critical
93
+ * Invariant).
94
+ */
13
95
  export function isStreamTransportError(error) {
14
96
  const seen = new Set();
15
97
  let current = error;
16
98
  while (current instanceof Error && !seen.has(current)) {
17
99
  seen.add(current);
18
- if ('code' in current && current.code === 'UND_ERR_SOCKET')
100
+ const code = current.code;
101
+ if (typeof code === 'string' && TRANSPORT_ERROR_CODES.has(code))
102
+ return true;
103
+ if (current.name === 'AbortError')
19
104
  return true;
20
105
  const message = current.message.toLowerCase().replace(/[.!]$/, '');
21
- if (message === 'terminated' ||
22
- message === 'other side closed' ||
23
- message === 'truncated event message received') {
106
+ if (TRANSPORT_ERROR_MESSAGES.has(message))
24
107
  return true;
25
- }
26
108
  current = current.cause;
27
109
  }
28
110
  return false;
29
111
  }
30
- export function isNetworkStreamError(error) {
31
- return error instanceof NetworkStreamError;
112
+ /**
113
+ * Messages Bedrock / Anthropic use for a context-window overflow — the subset of
114
+ * `ValidationException` worth surfacing with the more specific
115
+ * `context-window-exceeded` code (ties into spike item 04).
116
+ */
117
+ const CONTEXT_OVERFLOW_PATTERNS = /input is too long|prompt is too long|too many (input )?tokens|maximum context length|context (window|length)|exceed(s|ed)? (the )?(maximum )?context/i;
118
+ /**
119
+ * Recognizes the Bedrock `ValidationException` shape (8d), walking the `cause`
120
+ * chain and duck-typing the several ways `@anthropic-ai/bedrock-sdk` /
121
+ * `@aws-sdk` surface it (an `APIError` with a parsed `.error` body, an AWS
122
+ * `__type` tag, or the name / message text). Returns a {@link ValidationStreamError}
123
+ * carrying the extracted message + a typed code (`context-window-exceeded` for a
124
+ * context-overflow shape, else `validation-error`), or `undefined` when the
125
+ * error isn't a `ValidationException`.
126
+ */
127
+ export function recognizeBedrockValidationError(error) {
128
+ const seen = new Set();
129
+ let current = error;
130
+ while (current instanceof Error && !seen.has(current)) {
131
+ seen.add(current);
132
+ const message = extractValidationExceptionMessage(current);
133
+ if (message !== undefined) {
134
+ const code = CONTEXT_OVERFLOW_PATTERNS.test(message) ? CONTEXT_OVERFLOW_ERROR_CODE : VALIDATION_ERROR_CODE;
135
+ return new ValidationStreamError(message, code);
136
+ }
137
+ current = current.cause;
138
+ }
139
+ return undefined;
140
+ }
141
+ /**
142
+ * Returns the human-readable message when `err` is a Bedrock `ValidationException`,
143
+ * else `undefined`. Recognizes: the error `name`, an AWS `__type` tag, or a
144
+ * parsed `.error` body carrying `type` / `__type` — preferring the body's
145
+ * `message` when present so the actionable detail survives.
146
+ */
147
+ function extractValidationExceptionMessage(err) {
148
+ const body = err.error;
149
+ const bodyObj = typeof body === 'object' && body !== null ? body : undefined;
150
+ const isValidationException = err.name === 'ValidationException' ||
151
+ typeMatchesValidationException(err.__type) ||
152
+ (bodyObj !== undefined &&
153
+ (typeMatchesValidationException(bodyObj['type']) ||
154
+ typeMatchesValidationException(bodyObj['__type']) ||
155
+ typeMatchesValidationException(bodyObj['name']))) ||
156
+ /validationexception/i.test(err.message);
157
+ if (!isValidationException)
158
+ return undefined;
159
+ const bodyMessage = bodyObj !== undefined && typeof bodyObj['message'] === 'string' ? bodyObj['message'] : undefined;
160
+ return bodyMessage ?? err.message;
161
+ }
162
+ function typeMatchesValidationException(value) {
163
+ return typeof value === 'string' && value.includes('ValidationException');
164
+ }
165
+ /**
166
+ * Whether `error` is any harness-classified stream error (network, empty,
167
+ * gateway-response, validation, …). The event adapter uses this to copy the
168
+ * error's `code` onto the ChatEvent regardless of which classifier produced it.
169
+ */
170
+ export function isClassifiedStreamError(error) {
171
+ return error instanceof ClassifiedStreamError;
32
172
  }
33
173
  //# sourceMappingURL=stream-errors.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/sfdx-agent-harness-mastra",
3
- "version": "0.60.0",
3
+ "version": "0.62.0",
4
4
  "description": "Mastra-backed AgentHarness implementation for @salesforce/sfdx-agent-sdk",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -42,12 +42,12 @@
42
42
  "openai": "^7.0.0"
43
43
  },
44
44
  "peerDependencies": {
45
- "@salesforce/sfdx-agent-sdk": "0.61.0"
45
+ "@salesforce/sfdx-agent-sdk": "0.63.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@eslint/js": "^10.0.1",
49
49
  "@salesforce/harness-conformance": "0.0.1",
50
- "@salesforce/sfdx-agent-sdk": "0.61.0",
50
+ "@salesforce/sfdx-agent-sdk": "0.63.0",
51
51
  "@types/node": "^22.20.1",
52
52
  "@vitest/coverage-istanbul": "^4.1.10",
53
53
  "@vitest/eslint-plugin": "^1.6.27",