@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 +10 -0
- package/dist/mastra-event-adapter.js +2 -2
- package/dist/pass-through-anthropic.js +79 -9
- package/dist/pass-through-openai-chat-completions.js +30 -2
- package/dist/pass-through-openai.js +62 -15
- package/dist/stream-errors.d.ts +68 -2
- package/dist/stream-errors.js +148 -8
- package/package.json +3 -3
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 {
|
|
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
|
-
...(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
295
|
-
//
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
207
|
-
// V3 consumers see exactly one
|
|
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 });
|
package/dist/stream-errors.d.ts
CHANGED
|
@@ -1,8 +1,74 @@
|
|
|
1
1
|
export declare const NETWORK_ERROR_CODE: 'network-error';
|
|
2
|
-
export declare
|
|
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
|
-
|
|
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;
|
package/dist/stream-errors.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
31
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|