@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 +10 -0
- package/dist/mastra-approval-coordinator.d.ts +12 -0
- package/dist/mastra-approval-coordinator.js +14 -0
- package/dist/mastra-event-adapter.js +2 -2
- package/dist/mastra-harness.js +13 -5
- package/dist/pass-through-anthropic.js +73 -8
- package/dist/pass-through-openai-chat-completions.js +23 -0
- package/dist/pass-through-openai.js +55 -13
- 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.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 {
|
|
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':
|
package/dist/mastra-harness.js
CHANGED
|
@@ -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
|
-
//
|
|
1622
|
-
//
|
|
1623
|
-
//
|
|
1624
|
-
//
|
|
1625
|
-
// turn
|
|
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
|
-
|
|
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
|
|
300
|
-
//
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
|
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
|
|
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
|
|
212
|
-
// 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).
|
|
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 });
|
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.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.
|
|
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.
|
|
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",
|