@salesforce/sfdx-agent-harness-mastra 0.61.0 → 0.63.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.63.0] - 2026-09-02
7
+
8
+ ### Features
9
+ - **agent-sdk,harness-mastra,harness-claude,harness-openai**: reject concurrent turns with a typed SESSION_BUSY error @W-24044572@ ([#783](https://github.com/forcedotcom/agentic-dx/pull/783))
10
+
11
+ ## [0.62.0] - 2026-09-02
12
+
13
+ ### Fixes
14
+ - **harness-mastra**: classify empty/opaque gateway stream failures @W-24026850@ @W-24027340@ ([#782](https://github.com/forcedotcom/agentic-dx/pull/782))
15
+
6
16
  ## [0.61.0] - 2026-09-01
7
17
 
8
18
  ### Fixes
@@ -261,6 +261,18 @@ export declare class MastraApprovalCoordinator {
261
261
  private timedOut;
262
262
  constructor(opts: MastraApprovalCoordinatorOptions);
263
263
  initialSink(): GenSink<ChatEvent>;
264
+ /**
265
+ * True once this turn has reached a terminal event — a natural `finish`
266
+ * pushed by the pump, or the synthetic `error` + `finish('error')` pair a
267
+ * `teardown` (abort / dispose / timeout / pump-error) emits. Both end the
268
+ * single per-turn sink, so `initial.isEnded()` is the authoritative "this
269
+ * turn is over" signal. A coordinator still pumping — including one PARKED on
270
+ * a `tool-approval-request` or a consumer-tool `submitToolResult` — has not
271
+ * ended its sink and is therefore NOT settled. `MastraHarness.stream()` reads
272
+ * this to reject a concurrent turn with `SESSION_BUSY` (a still-active
273
+ * coordinator) versus safely replace a settled one.
274
+ */
275
+ isSettled(): boolean;
264
276
  /**
265
277
  * Mastra's current run id. The harness's MCP-progress dispatcher uses
266
278
  * this to filter incoming `notifications/progress` to the coordinator
@@ -184,6 +184,20 @@ export class MastraApprovalCoordinator {
184
184
  initialSink() {
185
185
  return this.initial;
186
186
  }
187
+ /**
188
+ * True once this turn has reached a terminal event — a natural `finish`
189
+ * pushed by the pump, or the synthetic `error` + `finish('error')` pair a
190
+ * `teardown` (abort / dispose / timeout / pump-error) emits. Both end the
191
+ * single per-turn sink, so `initial.isEnded()` is the authoritative "this
192
+ * turn is over" signal. A coordinator still pumping — including one PARKED on
193
+ * a `tool-approval-request` or a consumer-tool `submitToolResult` — has not
194
+ * ended its sink and is therefore NOT settled. `MastraHarness.stream()` reads
195
+ * this to reject a concurrent turn with `SESSION_BUSY` (a still-active
196
+ * coordinator) versus safely replace a settled one.
197
+ */
198
+ isSettled() {
199
+ return this.initial.isEnded();
200
+ }
187
201
  /**
188
202
  * Mastra's current run id. The harness's MCP-progress dispatcher uses
189
203
  * this to filter incoming `notifications/progress` to the coordinator
@@ -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':
@@ -1618,12 +1618,20 @@ export class MastraHarness {
1618
1618
  // round-trip per tool call.
1619
1619
  const { lookupPolicy, batchApprovals } = this.buildPolicyGate(agentState, options);
1620
1620
  const gatingActive = lookupPolicy !== undefined;
1621
- // Dispose any coordinator the prior turn on this thread allocated.
1622
- // Without this a stale coordinator from the prior turn would still
1623
- // route settle calls and accumulate listeners. Mode-flip safe: the
1624
- // new coordinator is constructed fresh below regardless of the new
1625
- // turn's mode.
1621
+ // Concurrency guard (spike agent-offline-failure-modes #10, W-24044572):
1622
+ // reject a second turn on a thread whose prior turn is still ACTIVE with a
1623
+ // typed `SESSION_BUSY` error, instead of silently disposing the first
1624
+ // (which surfaced to the first consumer as a spurious `DISPOSED` — a
1625
+ // "silent lost turn"). A coordinator is active until its sink ends (natural
1626
+ // finish or teardown), so one still pumping — including one parked on an
1627
+ // approval / consumer-tool result — blocks a new turn. An already-settled
1628
+ // coordinator (finished or errored) is safe to dispose-and-replace, so the
1629
+ // idempotent settling of an already-terminal turn is preserved.
1626
1630
  const priorCoordinator = agentState.coordinators.get(threadId);
1631
+ if (priorCoordinator && !priorCoordinator.isSettled()) {
1632
+ throw new AgentSDKError(`A turn is already in progress for thread "${threadId}" on agent "${agentId}". ` +
1633
+ 'Only one turn may run at a time per thread; await the in-flight turn before starting another.', AgentSDKErrorType.SESSION_BUSY);
1634
+ }
1627
1635
  if (priorCoordinator) {
1628
1636
  priorCoordinator.dispose();
1629
1637
  agentState.coordinators.delete(threadId);
@@ -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
@@ -173,6 +173,13 @@ async function doGenerate(ctx, options) {
173
173
  // Forward the turn's AbortSignal into the SDK's RequestOptions so firing the
174
174
  // controller (POST /cancel, idle timeout, shutdown abortAll) actually aborts
175
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.
176
183
  const message = await client.messages.create({ ...commonParams, stream: false }, { signal: options.abortSignal });
177
184
  const content = [];
178
185
  let sawToolCalls = false;
@@ -206,7 +213,37 @@ async function doStream(ctx, options) {
206
213
  const { client, commonParams } = await buildAnthropicRequest(ctx, options);
207
214
  // See doGenerate: thread the AbortSignal into the SDK so a mid-stream abort
208
215
  // tears down the live gateway socket (W-23988781).
209
- const sdkStream = await client.messages.create({ ...commonParams, stream: true }, { signal: options.abortSignal });
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
+ }
210
247
  const stream = new ReadableStream({
211
248
  start: async (controller) => {
212
249
  const textSpan = new TextSpanLifecycle();
@@ -214,6 +251,11 @@ async function doStream(ctx, options) {
214
251
  // sequence of input_json_delta strings on a single content block.
215
252
  const toolUseByIndex = new Map();
216
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;
217
259
  // Cumulative usage tracked across `message_start` and `message_delta`.
218
260
  let mergedUsage;
219
261
  let finishRaw;
@@ -225,6 +267,7 @@ async function doStream(ctx, options) {
225
267
  switch (event.type) {
226
268
  case 'message_start': {
227
269
  mergedUsage = mergeUsage(mergedUsage, event.message.usage);
270
+ sawContentOrUsage = true;
228
271
  // `stop_reason` is null on message_start in streaming mode.
229
272
  break;
230
273
  }
@@ -253,6 +296,7 @@ async function doStream(ctx, options) {
253
296
  const delta = event.delta;
254
297
  if (delta.type === 'text_delta') {
255
298
  textSpan.delta(controller, delta.text);
299
+ sawContentOrUsage = true;
256
300
  }
257
301
  else if (delta.type === 'input_json_delta') {
258
302
  const tu = toolUseByIndex.get(event.index);
@@ -268,6 +312,7 @@ async function doStream(ctx, options) {
268
312
  const tu = toolUseByIndex.get(event.index);
269
313
  if (tu) {
270
314
  sawToolCalls = true;
315
+ sawContentOrUsage = true;
271
316
  controller.enqueue({
272
317
  type: 'tool-call',
273
318
  toolCallId: tu.id,
@@ -283,6 +328,7 @@ async function doStream(ctx, options) {
283
328
  case 'message_delta': {
284
329
  mergedUsage = mergeUsage(mergedUsage, event.usage);
285
330
  finishRaw = event.delta.stop_reason;
331
+ sawContentOrUsage = true;
286
332
  break;
287
333
  }
288
334
  case 'message_stop': {
@@ -296,10 +342,25 @@ async function doStream(ctx, options) {
296
342
  }
297
343
  }
298
344
  }
299
- // Stream ended without an explicit `message_stop` — close out the span
300
- // 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.
301
353
  if (!terminalEmitted) {
302
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
+ }
303
364
  const finishReason = mapAnthropicFinishReason(finishRaw, sawToolCalls);
304
365
  const usage = finalizeUsage(mergedUsage, ctx, finishReason.raw);
305
366
  controller.enqueue({ type: 'finish', finishReason, usage });
@@ -316,10 +377,14 @@ async function doStream(ctx, options) {
316
377
  controller.close();
317
378
  return;
318
379
  }
319
- controller.enqueue({
320
- type: 'error',
321
- error: isStreamTransportError(error) ? new NetworkStreamError(error) : error,
322
- });
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 });
323
388
  controller.close();
324
389
  }
325
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
@@ -139,6 +140,11 @@ async function doStream(ctx, options) {
139
140
  let finishRaw;
140
141
  let usage;
141
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;
142
148
  try {
143
149
  for await (const chunk of sdkStream) {
144
150
  // OpenAI-compatible servers emit a final usage-only chunk when
@@ -147,6 +153,7 @@ async function doStream(ctx, options) {
147
153
  // populated `usage` field; treat it as a usage-only signal.
148
154
  if (chunk.usage) {
149
155
  usage = mapChatCompletionUsage(chunk.usage);
156
+ sawContentOrUsage = true;
150
157
  }
151
158
  const choice = chunk.choices[0];
152
159
  if (!choice)
@@ -154,6 +161,7 @@ async function doStream(ctx, options) {
154
161
  const delta = choice.delta;
155
162
  if (delta.content) {
156
163
  textSpan.delta(controller, delta.content);
164
+ sawContentOrUsage = true;
157
165
  }
158
166
  if (delta.tool_calls) {
159
167
  // V3 ordering: any open text span must close BEFORE tool-call parts
@@ -164,6 +172,7 @@ async function doStream(ctx, options) {
164
172
  // and pass-through-openai.ts both rotate eagerly at the same moment).
165
173
  // `rotate` is a no-op when no text span is open.
166
174
  textSpan.rotate(controller);
175
+ sawContentOrUsage = true;
167
176
  for (const tc of delta.tool_calls) {
168
177
  const idx = tc.index;
169
178
  let acc = toolCallsByIndex.get(idx);
@@ -189,6 +198,7 @@ async function doStream(ctx, options) {
189
198
  }
190
199
  if (choice.finish_reason !== null && choice.finish_reason !== undefined) {
191
200
  finishRaw = choice.finish_reason;
201
+ sawContentOrUsage = true;
192
202
  // Flush any buffered tool calls. Chat Completions doesn't send a
193
203
  // discrete "tool_call done" event — they're complete once the
194
204
  // chunk's `finish_reason` arrives.
@@ -226,6 +236,19 @@ async function doStream(ctx, options) {
226
236
  }
227
237
  }
228
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
+ }
229
252
  const finishReason = mapChatCompletionFinishReason(finishRaw ?? null, sawToolCalls);
230
253
  const finalUsage = usage ?? mapChatCompletionUsage(undefined);
231
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
@@ -118,11 +119,19 @@ async function doStream(ctx, options) {
118
119
  const toolCallById = new Map();
119
120
  let sawToolCalls = false;
120
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;
121
129
  try {
122
130
  for await (const event of sdkStream) {
123
131
  switch (event.type) {
124
132
  case 'response.output_text.delta': {
125
133
  textSpan.delta(controller, event.delta);
134
+ sawContent = true;
126
135
  break;
127
136
  }
128
137
  case 'response.output_item.added': {
@@ -151,6 +160,7 @@ async function doStream(ctx, options) {
151
160
  const tc = toolCallById.get(item.id ?? item.call_id);
152
161
  const finalArgs = item.arguments ?? tc?.argsBuffer ?? '';
153
162
  sawToolCalls = true;
163
+ sawContent = true;
154
164
  controller.enqueue({
155
165
  type: 'tool-call',
156
166
  toolCallId: item.call_id,
@@ -175,19 +185,33 @@ async function doStream(ctx, options) {
175
185
  controller.close();
176
186
  return;
177
187
  }
178
- 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
+ }
179
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'`.
180
212
  textSpan.closeIfOpen(controller);
181
213
  const response = event.response;
182
- const rawStatus = response.status;
183
- // For `response.failed`, `status` is authoritative — drop any
184
- // `incomplete_details.reason` the upstream may have populated so
185
- // the mapper lands on its `status === 'failed'` arm and resolves
186
- // `unified='error'`. Forwarding the reason unconditionally would
187
- // demote `'error'` to `'length'` / `'content-filter'` on a
188
- // malformed-but-possible upstream shape.
189
- const incompleteReason = rawStatus === 'failed' ? undefined : response.incomplete_details?.reason;
190
- const finishReason = mapOpenAIFinishReason(rawStatus, sawToolCalls, incompleteReason);
214
+ const finishReason = mapOpenAIFinishReason(response.status, sawToolCalls, response.incomplete_details?.reason);
191
215
  const usage = finalizeUsage(response.usage, ctx, finishReason.raw);
192
216
  controller.enqueue({ type: 'finish', finishReason, usage });
193
217
  terminalEmitted = true;
@@ -195,9 +219,16 @@ async function doStream(ctx, options) {
195
219
  return;
196
220
  }
197
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).
198
225
  controller.enqueue({
199
226
  type: 'error',
200
- 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
+ }),
201
232
  });
202
233
  terminalEmitted = true;
203
234
  controller.close();
@@ -208,10 +239,21 @@ async function doStream(ctx, options) {
208
239
  // ignored — they're not in the V3 ChatEvent surface today.
209
240
  }
210
241
  }
211
- // Stream ended without an explicit terminal event — synthesize one so
212
- // 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).
213
246
  if (!terminalEmitted) {
214
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
+ }
215
257
  const finishReason = mapOpenAIFinishReason(undefined, sawToolCalls, undefined);
216
258
  const usage = finalizeUsage(undefined, ctx, finishReason.raw);
217
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.61.0",
3
+ "version": "0.63.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.62.0"
45
+ "@salesforce/sfdx-agent-sdk": "0.64.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.62.0",
50
+ "@salesforce/sfdx-agent-sdk": "0.64.0",
51
51
  "@types/node": "^22.20.1",
52
52
  "@vitest/coverage-istanbul": "^4.1.10",
53
53
  "@vitest/eslint-plugin": "^1.6.27",