@salesforce/sfdx-agent-harness-mastra 0.96.0 → 0.97.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 +5 -0
- package/README.md +3 -2
- package/dist/index.d.ts +10 -2
- package/dist/mastra-harness.d.ts +8 -1
- package/dist/mastra-harness.js +27 -9
- package/dist/pass-through-anthropic.js +4 -0
- package/dist/pass-through-openai-chat-completions.js +2 -0
- package/dist/pass-through-openai.js +2 -0
- package/dist/retry-pre-first-token.d.ts +29 -0
- package/dist/retry-pre-first-token.js +356 -0
- package/dist/stream-errors.d.ts +10 -0
- package/dist/stream-errors.js +12 -0
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,11 @@
|
|
|
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.97.0] - 2026-10-05
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
- **harness-mastra,agent-sdk**: retry transient LLM failures before first token @W-24236393@ ([#856](https://github.com/forcedotcom/agentic-dx/pull/856))
|
|
10
|
+
|
|
6
11
|
## [0.96.0] - 2026-10-03
|
|
7
12
|
|
|
8
13
|
_No changes — released alongside dependent packages._
|
package/README.md
CHANGED
|
@@ -29,8 +29,9 @@ internal architecture, see [ARCHITECTURE.md](ARCHITECTURE.md).
|
|
|
29
29
|
This in-process harness emits structured `llm-request` / `llm-response` wire events. Matching events carry the
|
|
30
30
|
harness-generated `correlationId`; `llm-response` provides best-effort `responseText` and `timeToFirstTokenMs` after the
|
|
31
31
|
streaming body terminates. It also emits the debug-only `mcp-tool-call-completed` event; its `durationMs` is an
|
|
32
|
-
adapter/coordinator-observed window and can include a model round-trip. It
|
|
33
|
-
|
|
32
|
+
adapter/coordinator-observed window and can include a model round-trip. It emits numeric-only, PII-free `llm-retry`
|
|
33
|
+
telemetry when `withPreFirstTokenRetry` re-issues a turn after a transient establishment or in-band failure before the
|
|
34
|
+
first consumer-visible part. The fetch wrapper itself stays stateless. See the SDK README's
|
|
34
35
|
[cross-harness coverage table](../sfdx-agent-sdk/README.md#cross-harness-coverage) for the complete contract and
|
|
35
36
|
cross-harness comparison.
|
|
36
37
|
|
package/dist/index.d.ts
CHANGED
|
@@ -11,7 +11,9 @@ export { type MastraAgentConfig, type MastraSkillSearchConfig, type MastraToolSe
|
|
|
11
11
|
* `MastraHarnessFactoryConfig.languageModelBuilders`. The shape mirrors what
|
|
12
12
|
* `MastraHarness.buildLanguageModel` currently hands its internal builders, so an
|
|
13
13
|
* injected builder receives the same context bag and can compose wire monitoring +
|
|
14
|
-
* proxy routing identically to the native pass-through builders.
|
|
14
|
+
* proxy routing identically to the native pass-through builders. The harness wraps
|
|
15
|
+
* the returned model with pre-first-token retry while preserving `specificationVersion`,
|
|
16
|
+
* `provider`, `modelId`, and `supportedUrls`.
|
|
15
17
|
*
|
|
16
18
|
* @remarks
|
|
17
19
|
* All fields come from the harness:
|
|
@@ -37,7 +39,13 @@ export type MastraLanguageModelBuilderContext = {
|
|
|
37
39
|
* (e.g. a temporary GovCloud `chat/generations` fallback). The builder receives the
|
|
38
40
|
* same context bag the harness's built-in pass-through builders get, so an injected
|
|
39
41
|
* builder can emit `WireCommunicationEvent`s and honor `HTTPS_PROXY` routing without
|
|
40
|
-
* duplicating the harness's own fetch wrapper / wire-monitoring plumbing.
|
|
42
|
+
* duplicating the harness's own fetch wrapper / wire-monitoring plumbing. The
|
|
43
|
+
* returned model is decorated with pre-first-token retry (W-24236393). Transient
|
|
44
|
+
* errors the builder throws from `doStream` / `doGenerate` (HTTP 429/5xx, classified
|
|
45
|
+
* network-error, rate-limit bodies) may be retried before any consumer-visible part.
|
|
46
|
+
* The wrapper adds up to three outer attempts; the harness disables native retries only on its
|
|
47
|
+
* built-in provider clients, so disable retries inside an injected builder to avoid
|
|
48
|
+
* multiplicative requests.
|
|
41
49
|
*
|
|
42
50
|
* @example
|
|
43
51
|
* ```ts
|
package/dist/mastra-harness.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { type HttpErrorCapture } from './mcp-config-mapper.js';
|
|
|
9
9
|
import { type MastraAgentConfig, type SkillSearchHandle, type ToolSearchHandle } from './tool-search.js';
|
|
10
10
|
import { type MultimodalFile } from '@salesforce/sfdx-agent-sdk';
|
|
11
11
|
import type { LanguageModelV3 } from '@ai-sdk/provider';
|
|
12
|
-
import { Clock, type LogRecord, type UniqueIDGenerator, type Unsubscribe } from '@salesforce/agentic-common';
|
|
12
|
+
import { Clock, type LogRecord, type BackoffRetryerDeps, type UniqueIDGenerator, type Unsubscribe } from '@salesforce/agentic-common';
|
|
13
13
|
import { type ToolsInput } from './utils.js';
|
|
14
14
|
import { Workspace } from '@mastra/core/workspace';
|
|
15
15
|
/**
|
|
@@ -254,6 +254,11 @@ export interface MastraHarnessOptions {
|
|
|
254
254
|
* handle a custom hint without editing the harness's internal switch.
|
|
255
255
|
*/
|
|
256
256
|
languageModelBuilders?: Readonly<Record<string, (ctx: MastraLanguageModelBuilderContext) => LanguageModelV3>>;
|
|
257
|
+
/**
|
|
258
|
+
* Clock / sleep / RNG for the pre-first-token LLM retry loop (W-24236393), whose policy is
|
|
259
|
+
* `PRE_FIRST_TOKEN_RETRY_OPTIONS`. Tests inject a no-sleep `sleep`.
|
|
260
|
+
*/
|
|
261
|
+
llmRetryerDeps?: Partial<BackoffRetryerDeps>;
|
|
257
262
|
}
|
|
258
263
|
export declare class MastraHarness implements MastraAgentHarness {
|
|
259
264
|
private readonly storageRootFolder;
|
|
@@ -314,6 +319,7 @@ export declare class MastraHarness implements MastraAgentHarness {
|
|
|
314
319
|
private readonly innerFetch;
|
|
315
320
|
private readonly wireObserverAbortController;
|
|
316
321
|
private readonly languageModelBuilders?;
|
|
322
|
+
private readonly llmRetryerDeps;
|
|
317
323
|
/**
|
|
318
324
|
* Per-turn, per-thread consumer/frontend tool-args retry counters (S1,
|
|
319
325
|
* W-24023028): `agentId → threadId → (toolName → consecutive-failure count)`.
|
|
@@ -531,6 +537,7 @@ export declare class MastraHarness implements MastraAgentHarness {
|
|
|
531
537
|
*/
|
|
532
538
|
private resolveToolArgsRetryCounts;
|
|
533
539
|
private buildLanguageModel;
|
|
540
|
+
private wrapLanguageModel;
|
|
534
541
|
private destroyThreadBestEffort;
|
|
535
542
|
/**
|
|
536
543
|
* @requirements
|
package/dist/mastra-harness.js
CHANGED
|
@@ -30,6 +30,7 @@ import { buildOpenAILanguageModel } from './pass-through-openai.js';
|
|
|
30
30
|
import { readToolArgsRetryScope, TOOL_ARGS_RETRY_SCOPE_AGENT_ID, TOOL_ARGS_RETRY_SCOPE_PROVIDER, TOOL_ARGS_RETRY_SCOPE_THREAD_ID, } from './v3-stream-mapping.js';
|
|
31
31
|
import { ToolResultRedactionProcessor } from './mastra-tool-result-redaction-processor.js';
|
|
32
32
|
import { backfillCreatedAt, buildSummaryPrompt, Clock, getErrorMessage, RealClock, splitFrontmatterAndBody, UUIDGenerator, } from '@salesforce/agentic-common';
|
|
33
|
+
import { withPreFirstTokenRetry } from './retry-pre-first-token.js';
|
|
33
34
|
import { makeAbortable } from './utils.js';
|
|
34
35
|
import { Workspace, LocalFilesystem, LocalSandbox, WORKSPACE_TOOLS, WORKSPACE_TOOLS_PREFIX, } from '@mastra/core/workspace';
|
|
35
36
|
/**
|
|
@@ -486,6 +487,7 @@ export class MastraHarness {
|
|
|
486
487
|
innerFetch;
|
|
487
488
|
wireObserverAbortController = new AbortController();
|
|
488
489
|
languageModelBuilders;
|
|
490
|
+
llmRetryerDeps;
|
|
489
491
|
/**
|
|
490
492
|
* Per-turn, per-thread consumer/frontend tool-args retry counters (S1,
|
|
491
493
|
* W-24023028): `agentId → threadId → (toolName → consecutive-failure count)`.
|
|
@@ -516,6 +518,7 @@ export class MastraHarness {
|
|
|
516
518
|
this.idleReadTimeoutMs = opts.idleReadTimeoutMs ?? DEFAULT_IDLE_READ_TIMEOUT_MS;
|
|
517
519
|
this.innerFetch = opts.innerFetch ?? globalThis.fetch;
|
|
518
520
|
this.languageModelBuilders = opts.languageModelBuilders;
|
|
521
|
+
this.llmRetryerDeps = { clock: this.clock, ...opts.llmRetryerDeps };
|
|
519
522
|
this.storage = new LibSQLStore({
|
|
520
523
|
id: 'mastra-storage',
|
|
521
524
|
url: `file:${this.storageRootFolder}/mastra.db`,
|
|
@@ -1615,7 +1618,7 @@ export class MastraHarness {
|
|
|
1615
1618
|
switch (initial.providerHint) {
|
|
1616
1619
|
case 'bedrock-anthropic':
|
|
1617
1620
|
// Salesforce LLM Gateway's `/invoke-with-response-stream` (Bedrock pass-through).
|
|
1618
|
-
return buildAnthropicLanguageModel({
|
|
1621
|
+
return this.wrapLanguageModel(buildAnthropicLanguageModel({
|
|
1619
1622
|
getInfo,
|
|
1620
1623
|
logBus,
|
|
1621
1624
|
wireHooks,
|
|
@@ -1624,11 +1627,11 @@ export class MastraHarness {
|
|
|
1624
1627
|
getConsumerToolSchemas,
|
|
1625
1628
|
getToolArgsRetryCounts,
|
|
1626
1629
|
idleReadTimeoutMs,
|
|
1627
|
-
});
|
|
1630
|
+
}));
|
|
1628
1631
|
case 'anthropic':
|
|
1629
1632
|
// Direct Anthropic Messages API (`api.anthropic.com/v1/messages`) —
|
|
1630
1633
|
// different wire shape (`x-api-key` auth, body without `anthropic_version`).
|
|
1631
|
-
return buildAnthropicDirectLanguageModel({
|
|
1634
|
+
return this.wrapLanguageModel(buildAnthropicDirectLanguageModel({
|
|
1632
1635
|
getInfo,
|
|
1633
1636
|
logBus,
|
|
1634
1637
|
wireHooks,
|
|
@@ -1637,13 +1640,13 @@ export class MastraHarness {
|
|
|
1637
1640
|
getConsumerToolSchemas,
|
|
1638
1641
|
getToolArgsRetryCounts,
|
|
1639
1642
|
idleReadTimeoutMs,
|
|
1640
|
-
});
|
|
1643
|
+
}));
|
|
1641
1644
|
case 'openai-responses':
|
|
1642
1645
|
case 'openai':
|
|
1643
1646
|
// OpenAI Responses API. Salesforce LLM Gateway's `/responses` pass-through
|
|
1644
1647
|
// for `'openai-responses'`; `api.openai.com/v1/responses` for `'openai'` direct.
|
|
1645
1648
|
// Same wire shape; the only difference is the `baseUrl` the resolver supplies.
|
|
1646
|
-
return buildOpenAILanguageModel({
|
|
1649
|
+
return this.wrapLanguageModel(buildOpenAILanguageModel({
|
|
1647
1650
|
getInfo,
|
|
1648
1651
|
logBus,
|
|
1649
1652
|
wireHooks,
|
|
@@ -1652,12 +1655,12 @@ export class MastraHarness {
|
|
|
1652
1655
|
getConsumerToolSchemas,
|
|
1653
1656
|
getToolArgsRetryCounts,
|
|
1654
1657
|
idleReadTimeoutMs,
|
|
1655
|
-
});
|
|
1658
|
+
}));
|
|
1656
1659
|
case 'openai-compatible':
|
|
1657
1660
|
// OpenAI-compatible Chat Completions API (LLMG Express, LiteLLM, etc.).
|
|
1658
1661
|
// Different wire shape from Responses — `/v1/chat/completions` with
|
|
1659
1662
|
// `choices[0].delta` streaming events.
|
|
1660
|
-
return buildOpenAIChatCompletionsLanguageModel({
|
|
1663
|
+
return this.wrapLanguageModel(buildOpenAIChatCompletionsLanguageModel({
|
|
1661
1664
|
getInfo,
|
|
1662
1665
|
logBus,
|
|
1663
1666
|
wireHooks,
|
|
@@ -1666,7 +1669,7 @@ export class MastraHarness {
|
|
|
1666
1669
|
getConsumerToolSchemas,
|
|
1667
1670
|
getToolArgsRetryCounts,
|
|
1668
1671
|
idleReadTimeoutMs,
|
|
1669
|
-
});
|
|
1672
|
+
}));
|
|
1670
1673
|
default: {
|
|
1671
1674
|
// Consult the injected builders map before throwing. This is the
|
|
1672
1675
|
// extension point for consumer-registered wire shapes (e.g. a temporary
|
|
@@ -1674,7 +1677,7 @@ export class MastraHarness {
|
|
|
1674
1677
|
// the same context bag the built-in builders get.
|
|
1675
1678
|
const custom = this.languageModelBuilders?.[initial.providerHint];
|
|
1676
1679
|
if (custom) {
|
|
1677
|
-
return custom({ getInfo, logBus, emitWireCommunication, innerFetch });
|
|
1680
|
+
return this.wrapLanguageModel(custom({ getInfo, logBus, emitWireCommunication, innerFetch }));
|
|
1678
1681
|
}
|
|
1679
1682
|
// Defensive — manager-side G8 should have caught this before
|
|
1680
1683
|
// we got here. Make the error typed so consumers can branch
|
|
@@ -1683,6 +1686,21 @@ export class MastraHarness {
|
|
|
1683
1686
|
}
|
|
1684
1687
|
}
|
|
1685
1688
|
}
|
|
1689
|
+
wrapLanguageModel(inner) {
|
|
1690
|
+
return withPreFirstTokenRetry(inner, {
|
|
1691
|
+
retryerDeps: this.llmRetryerDeps,
|
|
1692
|
+
logBus: this.buses.getLogBus(),
|
|
1693
|
+
emitTelemetry: (event) => {
|
|
1694
|
+
try {
|
|
1695
|
+
this.buses.emitTelemetry({ ...event, timestamp: this.clock.now() });
|
|
1696
|
+
}
|
|
1697
|
+
catch (err) {
|
|
1698
|
+
if (!isDisposedError(err))
|
|
1699
|
+
throw err;
|
|
1700
|
+
}
|
|
1701
|
+
},
|
|
1702
|
+
});
|
|
1703
|
+
}
|
|
1686
1704
|
async destroyThreadBestEffort(agentId, threadId) {
|
|
1687
1705
|
try {
|
|
1688
1706
|
await this.destroyThread(agentId, threadId);
|
|
@@ -35,6 +35,8 @@ export function buildAnthropicLanguageModel(opts) {
|
|
|
35
35
|
buildClient: (info) => new AnthropicBedrock({
|
|
36
36
|
baseURL: info.baseUrl,
|
|
37
37
|
skipAuth: true,
|
|
38
|
+
// `withPreFirstTokenRetry` is the single retry owner; the SDK's built-in retries would multiply with it.
|
|
39
|
+
maxRetries: 0,
|
|
38
40
|
// The fetch wrapper re-evaluates `getHeaders()` on every outbound call —
|
|
39
41
|
// JWT rotation, premium-rate-limit gate flips, BYOK provider rotation
|
|
40
42
|
// all land on the next request without rebuilding the bridge.
|
|
@@ -96,6 +98,8 @@ export function buildAnthropicDirectLanguageModel(opts) {
|
|
|
96
98
|
new Anthropic({
|
|
97
99
|
apiKey: 'anthropic-direct-resolver-managed',
|
|
98
100
|
baseURL: info.baseUrl,
|
|
101
|
+
// `withPreFirstTokenRetry` is the single retry owner; the SDK's built-in retries would multiply with it.
|
|
102
|
+
maxRetries: 0,
|
|
99
103
|
fetch: createGatewayFetch(() => translateBearerToXApiKey(info), ctx.wireHooks ?? buildWireHooks(ctx), innerFetch, idleReadTimeoutMs, logBus),
|
|
100
104
|
}),
|
|
101
105
|
};
|
|
@@ -65,6 +65,8 @@ async function buildRequest(ctx, options) {
|
|
|
65
65
|
: undefined);
|
|
66
66
|
const client = new OpenAI({
|
|
67
67
|
baseURL: info.baseUrl,
|
|
68
|
+
// `withPreFirstTokenRetry` is the single retry owner; the SDK's built-in retries would multiply with it.
|
|
69
|
+
maxRetries: 0,
|
|
68
70
|
// The custom fetch injects the resolver-built `Authorization` header per
|
|
69
71
|
// request. The SDK still wants a non-empty `apiKey` at construction;
|
|
70
72
|
// an opaque sentinel keeps it from reading `process.env['OPENAI_API_KEY']`.
|
|
@@ -61,6 +61,8 @@ async function buildOpenAIRequest(ctx, options) {
|
|
|
61
61
|
: undefined);
|
|
62
62
|
const client = new OpenAI({
|
|
63
63
|
baseURL: info.baseUrl,
|
|
64
|
+
// `withPreFirstTokenRetry` is the single retry owner; the SDK's built-in retries would multiply with it.
|
|
65
|
+
maxRetries: 0,
|
|
64
66
|
// The custom fetch injects the resolver-built `Authorization` header per
|
|
65
67
|
// request. The SDK still wants a non-empty `apiKey` in case the fetch
|
|
66
68
|
// doesn't override the SDK-set Authorization; passing an opaque sentinel
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { LanguageModelV3 } from '@ai-sdk/provider';
|
|
2
|
+
import { type BackoffRetryerDeps, type LogBus, type RetryOptions } from '@salesforce/agentic-common';
|
|
3
|
+
import type { LlmRetryEvent } from '@salesforce/sfdx-agent-sdk';
|
|
4
|
+
export declare const PRE_FIRST_TOKEN_RETRY_OPTIONS: {
|
|
5
|
+
readonly maxAttempts: 3;
|
|
6
|
+
readonly maxTotalElapsedMs: 30000;
|
|
7
|
+
readonly initialDelayMs: 1000;
|
|
8
|
+
readonly maxDelayMs: 8000;
|
|
9
|
+
};
|
|
10
|
+
/** The single retry policy: builds the production retryer, bounds the hard deadline, and labels telemetry. */
|
|
11
|
+
export type PreFirstTokenRetryPolicy = RetryOptions & {
|
|
12
|
+
maxAttempts: number;
|
|
13
|
+
maxTotalElapsedMs: number;
|
|
14
|
+
};
|
|
15
|
+
export type PreFirstTokenRetryOptions = {
|
|
16
|
+
/** Default: {@link PRE_FIRST_TOKEN_RETRY_OPTIONS}. `maxTotalElapsedMs` is also the hard pre-first-token deadline. */
|
|
17
|
+
policy?: PreFirstTokenRetryPolicy;
|
|
18
|
+
/** Test seam for the retryer's clock / sleep / RNG. */
|
|
19
|
+
retryerDeps?: Partial<BackoffRetryerDeps>;
|
|
20
|
+
/** Must swallow its own expected failures (e.g. a disposed bus); anything thrown propagates into the retry loop. */
|
|
21
|
+
emitTelemetry?: (event: Omit<LlmRetryEvent, 'timestamp'>) => void;
|
|
22
|
+
logBus?: LogBus;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Whether `err` is a transient LLM failure worth re-issuing before any consumer-visible part. Shared by
|
|
26
|
+
* establishment throws and in-band `error` stream parts, which carry the same wire-level shapes.
|
|
27
|
+
*/
|
|
28
|
+
export declare function isTransientLlmError(err: unknown): boolean;
|
|
29
|
+
export declare function withPreFirstTokenRetry(inner: LanguageModelV3, opts?: PreFirstTokenRetryOptions): LanguageModelV3;
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026, Salesforce, Inc. All rights reserved.
|
|
3
|
+
* See LICENSE.txt for license terms.
|
|
4
|
+
*/
|
|
5
|
+
import { BackoffRetryer, getErrorMessage, isAbortError, RealClock, } from '@salesforce/agentic-common';
|
|
6
|
+
import { CONTEXT_OVERFLOW_ERROR_CODE, EMPTY_STREAM_ERROR_CODE, isClassifiedStreamError, isStreamTransportError, NETWORK_ERROR_CODE, PreFirstTokenDeadlineError, VALIDATION_ERROR_CODE, } from './stream-errors.js';
|
|
7
|
+
/**
|
|
8
|
+
* Mirrors the retryable set of the provider SDKs (`openai`, `@anthropic-ai/sdk`) whose native retries this wrapper
|
|
9
|
+
* replaces (`maxRetries: 0`): 408 timeout, 409 lock timeout, 429, and every 5xx. Keep in step with those SDKs'
|
|
10
|
+
* `shouldRetry`.
|
|
11
|
+
*/
|
|
12
|
+
function isRetryableStatus(status) {
|
|
13
|
+
return status === 408 || status === 409 || status === 429 || (status >= 500 && status < 600);
|
|
14
|
+
}
|
|
15
|
+
/** Provider-SDK connection failures (no HTTP status); the SDKs retry these natively. */
|
|
16
|
+
const SDK_CONNECTION_ERROR_NAMES = new Set(['APIConnectionError', 'APIConnectionTimeoutError']);
|
|
17
|
+
const RETRYABLE_TERMINAL_CODES = new Set([
|
|
18
|
+
'rate_limit_exceeded',
|
|
19
|
+
'server_error',
|
|
20
|
+
'overloaded_error',
|
|
21
|
+
'PROVIDER_ERROR',
|
|
22
|
+
EMPTY_STREAM_ERROR_CODE,
|
|
23
|
+
NETWORK_ERROR_CODE,
|
|
24
|
+
]);
|
|
25
|
+
const PERMANENT_TERMINAL_CODES = new Set([
|
|
26
|
+
VALIDATION_ERROR_CODE,
|
|
27
|
+
CONTEXT_OVERFLOW_ERROR_CODE,
|
|
28
|
+
'invalid_prompt',
|
|
29
|
+
'content-filter',
|
|
30
|
+
]);
|
|
31
|
+
const RATE_LIMIT_BODY = /rate limit reached/i;
|
|
32
|
+
const OVERLOADED_BODY = /overloaded/i;
|
|
33
|
+
export const PRE_FIRST_TOKEN_RETRY_OPTIONS = {
|
|
34
|
+
maxAttempts: 3,
|
|
35
|
+
maxTotalElapsedMs: 30_000,
|
|
36
|
+
initialDelayMs: 1_000,
|
|
37
|
+
maxDelayMs: 8_000,
|
|
38
|
+
};
|
|
39
|
+
const PRELUDE_PART_TYPES = new Set(['stream-start', 'response-metadata', 'raw']);
|
|
40
|
+
/**
|
|
41
|
+
* Cap on buffered prelude parts. A model that emits this many without any real content is treated as committed
|
|
42
|
+
* rather than buffered further: retry is disabled for that turn (fail safe) instead of growing without bound.
|
|
43
|
+
*/
|
|
44
|
+
const MAX_PRELUDE_PARTS = 32;
|
|
45
|
+
/** Largest delay `setTimeout` honors (2^31 - 1 ms); larger values fire almost immediately. */
|
|
46
|
+
const MAX_TIMER_MS = 2_147_483_647;
|
|
47
|
+
/**
|
|
48
|
+
* Whether `err` is a transient LLM failure worth re-issuing before any consumer-visible part. Shared by
|
|
49
|
+
* establishment throws and in-band `error` stream parts, which carry the same wire-level shapes.
|
|
50
|
+
*/
|
|
51
|
+
export function isTransientLlmError(err) {
|
|
52
|
+
if (isAbortError(err))
|
|
53
|
+
return false;
|
|
54
|
+
if (isClassifiedStreamError(err)) {
|
|
55
|
+
if (PERMANENT_TERMINAL_CODES.has(err.code))
|
|
56
|
+
return false;
|
|
57
|
+
if (RETRYABLE_TERMINAL_CODES.has(err.code))
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
// The provider SDKs let the server override their status heuristics with `x-should-retry`; keep that parity.
|
|
61
|
+
const hint = readErrorHeader(err, 'x-should-retry')?.trim().toLowerCase();
|
|
62
|
+
if (hint === 'false')
|
|
63
|
+
return false;
|
|
64
|
+
if (hint === 'true')
|
|
65
|
+
return true;
|
|
66
|
+
return (hasRetryableStatus(err) || isSdkConnectionError(err) || isStreamTransportError(err) || bodyLooksTransient(err));
|
|
67
|
+
}
|
|
68
|
+
function isSdkConnectionError(err) {
|
|
69
|
+
return walkErrorObjects(err).some((rec) => SDK_CONNECTION_ERROR_NAMES.has(rec.constructor?.name));
|
|
70
|
+
}
|
|
71
|
+
/** First value of `name` from the `headers` of `err` or anything in its `cause` chain. */
|
|
72
|
+
function readErrorHeader(err, name) {
|
|
73
|
+
for (const rec of walkErrorObjects(err)) {
|
|
74
|
+
const value = readHeader(rec.headers, name);
|
|
75
|
+
if (value !== undefined)
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
function readHeader(headers, name) {
|
|
81
|
+
if (headers === undefined || headers === null)
|
|
82
|
+
return undefined;
|
|
83
|
+
let raw;
|
|
84
|
+
if (typeof headers.get === 'function') {
|
|
85
|
+
raw = headers.get(name);
|
|
86
|
+
}
|
|
87
|
+
else if (typeof headers === 'object') {
|
|
88
|
+
raw = Object.entries(headers).find(([key]) => key.toLowerCase() === name)?.[1];
|
|
89
|
+
}
|
|
90
|
+
return typeof raw === 'string' && raw.trim() !== '' ? raw : undefined;
|
|
91
|
+
}
|
|
92
|
+
/** `maxTotalElapsedMs` is a real timer here (not just a backoff budget), so it must be a usable `setTimeout` delay. */
|
|
93
|
+
function assertValidPolicy(policy) {
|
|
94
|
+
const { maxAttempts, maxTotalElapsedMs, initialDelayMs, maxDelayMs } = policy;
|
|
95
|
+
if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
|
|
96
|
+
throw new Error(`Invalid pre-first-token retry policy: maxAttempts must be an integer >= 1 (got ${maxAttempts}).`);
|
|
97
|
+
}
|
|
98
|
+
if (!Number.isFinite(maxTotalElapsedMs) || maxTotalElapsedMs <= 0 || maxTotalElapsedMs > MAX_TIMER_MS) {
|
|
99
|
+
throw new Error(`Invalid pre-first-token retry policy: maxTotalElapsedMs must be in (0, ${MAX_TIMER_MS}] (got ${maxTotalElapsedMs}).`);
|
|
100
|
+
}
|
|
101
|
+
const { backoffFactor, maxRetryAfterMs } = policy;
|
|
102
|
+
if (backoffFactor !== undefined && (!Number.isFinite(backoffFactor) || backoffFactor < 1)) {
|
|
103
|
+
throw new Error(`Invalid pre-first-token retry policy: backoffFactor must be a finite number >= 1 (got ${backoffFactor}).`);
|
|
104
|
+
}
|
|
105
|
+
if (maxRetryAfterMs !== undefined && (Number.isNaN(maxRetryAfterMs) || maxRetryAfterMs < 0)) {
|
|
106
|
+
throw new Error(`Invalid pre-first-token retry policy: maxRetryAfterMs must be a number >= 0 (got ${maxRetryAfterMs}).`);
|
|
107
|
+
}
|
|
108
|
+
for (const [name, value] of [
|
|
109
|
+
['initialDelayMs', initialDelayMs],
|
|
110
|
+
['maxDelayMs', maxDelayMs],
|
|
111
|
+
]) {
|
|
112
|
+
if (value !== undefined && (!Number.isFinite(value) || value < 0)) {
|
|
113
|
+
throw new Error(`Invalid pre-first-token retry policy: ${name} must be a finite number >= 0 (got ${value}).`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
export function withPreFirstTokenRetry(inner, opts = {}) {
|
|
118
|
+
const policy = opts.policy ?? PRE_FIRST_TOKEN_RETRY_OPTIONS;
|
|
119
|
+
assertValidPolicy(policy);
|
|
120
|
+
const ctx = {
|
|
121
|
+
policy,
|
|
122
|
+
retryer: new BackoffRetryer(policy, opts.retryerDeps),
|
|
123
|
+
clock: opts.retryerDeps?.clock ?? new RealClock(),
|
|
124
|
+
emitTelemetry: opts.emitTelemetry,
|
|
125
|
+
logBus: opts.logBus,
|
|
126
|
+
};
|
|
127
|
+
return {
|
|
128
|
+
specificationVersion: inner.specificationVersion,
|
|
129
|
+
provider: inner.provider,
|
|
130
|
+
modelId: inner.modelId,
|
|
131
|
+
supportedUrls: inner.supportedUrls,
|
|
132
|
+
doGenerate: (options) => retryDoGenerate(inner, options, ctx),
|
|
133
|
+
doStream: (options) => retryDoStream(inner, options, ctx),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
async function retryDoGenerate(inner, options, ctx) {
|
|
137
|
+
return withPreCommitDeadline(options, ctx, (bounded) => ctx.retryer.execute(
|
|
138
|
+
// `abortable` bounds the caller even if the model ignores the signal; it cannot stop that work.
|
|
139
|
+
() => abortable(Promise.resolve(inner.doGenerate(bounded)), bounded.abortSignal), {
|
|
140
|
+
signal: bounded.abortSignal,
|
|
141
|
+
isRetryableError: isTransientLlmError,
|
|
142
|
+
getRetryAfterMsFromError: (err) => extractRetryAfterMs(err, ctx.clock),
|
|
143
|
+
onRetry: (info) => emitRetry(ctx, info.attempt, info.delayMs, info.error),
|
|
144
|
+
}));
|
|
145
|
+
}
|
|
146
|
+
async function retryDoStream(inner, options, ctx) {
|
|
147
|
+
return withPreCommitDeadline(options, ctx, async (bounded) => {
|
|
148
|
+
const attempt = await ctx.retryer.execute(() => readUntilCommitOrRetryableError(inner, bounded), {
|
|
149
|
+
signal: bounded.abortSignal,
|
|
150
|
+
isRetryableError: isTransientLlmError,
|
|
151
|
+
getRetryAfterMsFromError: (err) => extractRetryAfterMs(err, ctx.clock),
|
|
152
|
+
isRetryableResult: (result) => result.retryable,
|
|
153
|
+
getRetryAfterMs: (result) => extractRetryAfterMs(bufferedError(result), ctx.clock),
|
|
154
|
+
onRetry: (info) => emitRetry(ctx, info.attempt, info.delayMs, info.error ?? bufferedError(info.result)),
|
|
155
|
+
});
|
|
156
|
+
return attempt.result;
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Runs `run` with `options.abortSignal` composed with a deadline timer, then disarms the timer. The timer
|
|
161
|
+
* only lives for the pre-commit window: once `run` settles (a committed stream or a result), later stream
|
|
162
|
+
* consumption is governed solely by the caller's own signal. A deadline abort surfaces as
|
|
163
|
+
* {@link PreFirstTokenDeadlineError}; a caller abort propagates unchanged.
|
|
164
|
+
*/
|
|
165
|
+
async function withPreCommitDeadline(options, ctx, run) {
|
|
166
|
+
const deadlineMs = ctx.policy.maxTotalElapsedMs;
|
|
167
|
+
const deadline = new AbortController();
|
|
168
|
+
const timer = setTimeout(() => deadline.abort(), deadlineMs);
|
|
169
|
+
const abortSignal = options.abortSignal === undefined ? deadline.signal : AbortSignal.any([options.abortSignal, deadline.signal]);
|
|
170
|
+
try {
|
|
171
|
+
return await run({ ...options, abortSignal });
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
if (deadline.signal.aborted && options.abortSignal?.aborted !== true) {
|
|
175
|
+
throw new PreFirstTokenDeadlineError(deadlineMs);
|
|
176
|
+
}
|
|
177
|
+
throw err;
|
|
178
|
+
}
|
|
179
|
+
finally {
|
|
180
|
+
clearTimeout(timer);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Rejects with the signal's reason as soon as it aborts, even if `promise` (a non-cooperating model) never
|
|
185
|
+
* settles. Racing only bounds the caller: the underlying work keeps running, so callers that own a resource
|
|
186
|
+
* (the `doStream` result) must release a late settlement themselves.
|
|
187
|
+
*/
|
|
188
|
+
function abortable(promise, signal) {
|
|
189
|
+
if (signal === undefined)
|
|
190
|
+
return promise;
|
|
191
|
+
return new Promise((resolve, reject) => {
|
|
192
|
+
if (signal.aborted) {
|
|
193
|
+
reject(signal.reason);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const onAbort = () => reject(signal.reason);
|
|
197
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
198
|
+
promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort));
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
async function readUntilCommitOrRetryableError(inner, options) {
|
|
202
|
+
const signal = options.abortSignal;
|
|
203
|
+
signal?.throwIfAborted();
|
|
204
|
+
const pending = Promise.resolve(inner.doStream(options));
|
|
205
|
+
let result;
|
|
206
|
+
try {
|
|
207
|
+
result = await abortable(pending, signal);
|
|
208
|
+
}
|
|
209
|
+
catch (err) {
|
|
210
|
+
// The model may still resolve after we gave up on it; release that late stream.
|
|
211
|
+
pending.then((late) => late.stream.cancel()).catch(() => undefined);
|
|
212
|
+
throw err;
|
|
213
|
+
}
|
|
214
|
+
const reader = result.stream.getReader();
|
|
215
|
+
const buffered = [];
|
|
216
|
+
try {
|
|
217
|
+
for (;;) {
|
|
218
|
+
const { done, value } = await abortable(reader.read(), signal);
|
|
219
|
+
if (done) {
|
|
220
|
+
reader.releaseLock();
|
|
221
|
+
return { result: { ...result, stream: replayBuffered(buffered) }, buffered, retryable: false };
|
|
222
|
+
}
|
|
223
|
+
if (value === undefined)
|
|
224
|
+
continue;
|
|
225
|
+
buffered.push(value);
|
|
226
|
+
if (value.type === 'error' && isTransientLlmError(value.error)) {
|
|
227
|
+
// Release the upstream body now, whether or not another attempt follows (the final attempt
|
|
228
|
+
// is replayed from `buffered`). Not awaited: a non-cooperating cancel() must not stall the retry.
|
|
229
|
+
reader.cancel().catch(() => undefined);
|
|
230
|
+
return { result: { ...result, stream: replayBuffered(buffered) }, buffered, retryable: true };
|
|
231
|
+
}
|
|
232
|
+
if (commitsAttempt(value, buffered.length)) {
|
|
233
|
+
return {
|
|
234
|
+
result: { ...result, stream: replayBuffered(buffered, reader) },
|
|
235
|
+
buffered,
|
|
236
|
+
retryable: false,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
// A rejected read before commit (e.g. ECONNRESET) propagates to the retryer, which classifies it.
|
|
243
|
+
// Don't await the cancel: a non-cooperating stream may never settle it.
|
|
244
|
+
reader.cancel().catch(() => undefined);
|
|
245
|
+
throw err;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/** The commit boundary: retry is only allowed while every part so far is prelude and the buffer is under its cap. */
|
|
249
|
+
function commitsAttempt(part, bufferedCount) {
|
|
250
|
+
return part.type === 'error' || !PRELUDE_PART_TYPES.has(part.type) || bufferedCount >= MAX_PRELUDE_PARTS;
|
|
251
|
+
}
|
|
252
|
+
function replayBuffered(buffered, reader) {
|
|
253
|
+
let index = 0;
|
|
254
|
+
return new ReadableStream({
|
|
255
|
+
async pull(controller) {
|
|
256
|
+
if (index < buffered.length) {
|
|
257
|
+
controller.enqueue(buffered[index]);
|
|
258
|
+
index += 1;
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (reader === undefined) {
|
|
262
|
+
controller.close();
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
const next = await reader.read();
|
|
267
|
+
if (next.done) {
|
|
268
|
+
controller.close();
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
controller.enqueue(next.value);
|
|
272
|
+
}
|
|
273
|
+
catch (err) {
|
|
274
|
+
controller.error(err);
|
|
275
|
+
}
|
|
276
|
+
},
|
|
277
|
+
cancel(reason) {
|
|
278
|
+
return reader?.cancel(reason);
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
function bufferedError(attempt) {
|
|
283
|
+
const part = attempt?.buffered.find((item) => item.type === 'error');
|
|
284
|
+
return part?.type === 'error' ? part.error : undefined;
|
|
285
|
+
}
|
|
286
|
+
function emitRetry(ctx, attempt, retryDelayMs, cause) {
|
|
287
|
+
const errorStatus = extractStatus(cause);
|
|
288
|
+
const maxRetries = ctx.policy.maxAttempts;
|
|
289
|
+
ctx.emitTelemetry?.({
|
|
290
|
+
type: 'llm-retry',
|
|
291
|
+
attempt,
|
|
292
|
+
maxRetries,
|
|
293
|
+
retryDelayMs,
|
|
294
|
+
...(errorStatus !== undefined ? { errorStatus } : {}),
|
|
295
|
+
});
|
|
296
|
+
ctx.logBus?.warn('LLM retry', {
|
|
297
|
+
attempt,
|
|
298
|
+
maxRetries,
|
|
299
|
+
retryDelayMs,
|
|
300
|
+
...(errorStatus !== undefined ? { errorStatus } : {}),
|
|
301
|
+
error: getErrorMessage(cause),
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
function hasRetryableStatus(err) {
|
|
305
|
+
const status = extractStatus(err);
|
|
306
|
+
return status !== undefined && isRetryableStatus(status);
|
|
307
|
+
}
|
|
308
|
+
function extractStatus(err) {
|
|
309
|
+
for (const rec of walkErrorObjects(err)) {
|
|
310
|
+
for (const value of [rec.status, rec.statusCode]) {
|
|
311
|
+
if (typeof value === 'number' && Number.isInteger(value))
|
|
312
|
+
return value;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return undefined;
|
|
316
|
+
}
|
|
317
|
+
function extractRetryAfterMs(value, clock) {
|
|
318
|
+
for (const rec of walkErrorObjects(value)) {
|
|
319
|
+
const fromHeaders = parseRetryAfterHeaders(rec.headers, clock);
|
|
320
|
+
if (fromHeaders !== undefined)
|
|
321
|
+
return fromHeaders;
|
|
322
|
+
if (typeof rec.retryAfterMs === 'number' && Number.isFinite(rec.retryAfterMs))
|
|
323
|
+
return rec.retryAfterMs;
|
|
324
|
+
}
|
|
325
|
+
return undefined;
|
|
326
|
+
}
|
|
327
|
+
/** Same precedence as the provider SDKs: `retry-after-ms`, then `Retry-After` as delta-seconds or an HTTP date. */
|
|
328
|
+
function parseRetryAfterHeaders(headers, clock) {
|
|
329
|
+
const ms = Number(readHeader(headers, 'retry-after-ms'));
|
|
330
|
+
if (Number.isFinite(ms) && ms >= 0)
|
|
331
|
+
return Math.round(ms);
|
|
332
|
+
const raw = readHeader(headers, 'retry-after');
|
|
333
|
+
if (raw === undefined)
|
|
334
|
+
return undefined;
|
|
335
|
+
const seconds = Number(raw);
|
|
336
|
+
if (Number.isFinite(seconds) && seconds >= 0)
|
|
337
|
+
return Math.round(seconds * 1000);
|
|
338
|
+
const date = Date.parse(raw);
|
|
339
|
+
return Number.isNaN(date) ? undefined : Math.max(0, date - clock.now().getTime());
|
|
340
|
+
}
|
|
341
|
+
function walkErrorObjects(value) {
|
|
342
|
+
const seen = new Set();
|
|
343
|
+
const out = [];
|
|
344
|
+
let current = value;
|
|
345
|
+
while (current !== undefined && current !== null && typeof current === 'object' && !seen.has(current)) {
|
|
346
|
+
seen.add(current);
|
|
347
|
+
out.push(current);
|
|
348
|
+
current = current.cause;
|
|
349
|
+
}
|
|
350
|
+
return out;
|
|
351
|
+
}
|
|
352
|
+
function bodyLooksTransient(err) {
|
|
353
|
+
const message = getErrorMessage(err);
|
|
354
|
+
return RATE_LIMIT_BODY.test(message) || OVERLOADED_BODY.test(message);
|
|
355
|
+
}
|
|
356
|
+
//# sourceMappingURL=retry-pre-first-token.js.map
|
package/dist/stream-errors.d.ts
CHANGED
|
@@ -53,6 +53,16 @@ export declare class IdleReadTimeoutError extends ClassifiedStreamError {
|
|
|
53
53
|
readonly code: "network-error";
|
|
54
54
|
constructor(idleMs: number);
|
|
55
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* The pre-first-token retry deadline (`withPreFirstTokenRetry`, W-24236393) elapsed before any consumer-visible
|
|
58
|
+
* part arrived, across all attempts and backoff sleeps. Like {@link IdleReadTimeoutError} it classifies as
|
|
59
|
+
* {@link NETWORK_ERROR_CODE}; the wrapper has already stopped retrying by the time it is thrown.
|
|
60
|
+
*/
|
|
61
|
+
export declare class PreFirstTokenDeadlineError extends ClassifiedStreamError {
|
|
62
|
+
readonly name = "PreFirstTokenDeadlineError";
|
|
63
|
+
readonly code: "network-error";
|
|
64
|
+
constructor(deadlineMs: number);
|
|
65
|
+
}
|
|
56
66
|
/** A stream that closed cleanly with no recognized content/usage/terminal event — the "false-success" case (8a); `empty-stream`. */
|
|
57
67
|
export declare class EmptyStreamError extends ClassifiedStreamError {
|
|
58
68
|
readonly name = "EmptyStreamError";
|
package/dist/stream-errors.js
CHANGED
|
@@ -62,6 +62,18 @@ export class IdleReadTimeoutError extends ClassifiedStreamError {
|
|
|
62
62
|
'treating as a transport fault and tearing down the connection.');
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* The pre-first-token retry deadline (`withPreFirstTokenRetry`, W-24236393) elapsed before any consumer-visible
|
|
67
|
+
* part arrived, across all attempts and backoff sleeps. Like {@link IdleReadTimeoutError} it classifies as
|
|
68
|
+
* {@link NETWORK_ERROR_CODE}; the wrapper has already stopped retrying by the time it is thrown.
|
|
69
|
+
*/
|
|
70
|
+
export class PreFirstTokenDeadlineError extends ClassifiedStreamError {
|
|
71
|
+
name = 'PreFirstTokenDeadlineError';
|
|
72
|
+
code = NETWORK_ERROR_CODE;
|
|
73
|
+
constructor(deadlineMs) {
|
|
74
|
+
super(`LLM produced no first token within ${deadlineMs}ms across retries; giving up.`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
65
77
|
/** A stream that closed cleanly with no recognized content/usage/terminal event — the "false-success" case (8a); `empty-stream`. */
|
|
66
78
|
export class EmptyStreamError extends ClassifiedStreamError {
|
|
67
79
|
name = 'EmptyStreamError';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@salesforce/sfdx-agent-harness-mastra",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.97.0",
|
|
4
4
|
"description": "Mastra-backed AgentHarness implementation for @salesforce/sfdx-agent-sdk",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -37,17 +37,17 @@
|
|
|
37
37
|
"@mastra/mcp": "^1.18.0",
|
|
38
38
|
"@mastra/memory": "^1.30.0",
|
|
39
39
|
"@mastra/schema-compat": "^1.3.10",
|
|
40
|
-
"@salesforce/agentic-common": "0.
|
|
40
|
+
"@salesforce/agentic-common": "0.24.0",
|
|
41
41
|
"@smithy/eventstream-codec": "^4.5.2",
|
|
42
42
|
"openai": "^7.23.0"
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
|
-
"@salesforce/sfdx-agent-sdk": "0.
|
|
45
|
+
"@salesforce/sfdx-agent-sdk": "0.98.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.98.0",
|
|
51
51
|
"@types/node": "^22.20.1",
|
|
52
52
|
"@vitest/coverage-istanbul": "^5.0.1",
|
|
53
53
|
"@vitest/eslint-plugin": "^1.6.27",
|