@salesforce/sfdx-agent-harness-mastra 0.65.0 → 0.67.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,16 @@
3
3
  All notable changes to `@salesforce/sfdx-agent-harness-mastra` are documented in this file.
4
4
  Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
+ ## [0.67.0] - 2026-09-02
7
+
8
+ ### Fixes
9
+ - **harness-mastra**: wall-clock timeout on outbound resume RPC @W-24026787@ ([#790](https://github.com/forcedotcom/agentic-dx/pull/790))
10
+
11
+ ## [0.66.0] - 2026-09-02
12
+
13
+ ### Features
14
+ - **agent-sdk,harness-mastra,harness-claude,harness-openai**: classify context-window overflow as a typed CONTEXT_LENGTH_EXCEEDED error @W-24074717@ ([#788](https://github.com/forcedotcom/agentic-dx/pull/788))
15
+
6
16
  ## [0.65.0] - 2026-09-02
7
17
 
8
18
  _No changes — released alongside dependent packages._
@@ -35,6 +35,22 @@ export interface MastraApprovalCoordinatorOptions {
35
35
  };
36
36
  requestContext: RequestContext;
37
37
  toolApprovalTimeoutMs: number;
38
+ /**
39
+ * Wall-clock bound on the outbound resume RPC — `agent.approveToolCall(...)`
40
+ * / `agent.declineToolCall(...)` — NOT on the resumed turn's work (6c, spike
41
+ * agent-offline-failure-modes, W-24026787). Those calls return promptly with
42
+ * a fresh `fullStream` handle; the model's actual generation (which can be a
43
+ * long deployment or a slow "thinking" turn) streams through that handle,
44
+ * pumped separately with no timeout — so a healthy resume is never capped.
45
+ * This bound only fires when the establishment round-trip itself hangs and
46
+ * never hands back a stream: after a human decision the "waiting for a human"
47
+ * timer is already cancelled and the session is `busy`, so on plain-REST no
48
+ * idle timer or auto-decline sweep is left to recover it. On timeout the
49
+ * coordinator routes through the shared `teardown('timeout', …)` path so the
50
+ * sink still gets a terminal error+finish and the session is released rather
51
+ * than wedged. Defaults to {@link DEFAULT_RESUME_RPC_TIMEOUT_MS}.
52
+ */
53
+ resumeRpcTimeoutMs?: number;
38
54
  /**
39
55
  * Per-tool approval-decision callback (Phase 2a). When defined, gating is
40
56
  * **active**: the harness passed `requireToolApproval: true` to Mastra so
@@ -159,6 +175,7 @@ export declare class MastraApprovalCoordinator {
159
175
  private readonly memory;
160
176
  private readonly requestContext;
161
177
  private readonly toolApprovalTimeoutMs;
178
+ private readonly resumeRpcTimeoutMs;
162
179
  private readonly lookupPolicy?;
163
180
  private readonly batchApprovals;
164
181
  private readonly consumerToolNames;
@@ -23,6 +23,10 @@ function pickIdWithDecision(pendingIds, decisions) {
23
23
  return undefined;
24
24
  }
25
25
  const POLICY_DENIED_RESULT_TEXT = 'Denied by tool policy.';
26
+ /** Default {@link MastraApprovalCoordinatorOptions.resumeRpcTimeoutMs} — 5 minutes. */
27
+ const DEFAULT_RESUME_RPC_TIMEOUT_MS = 300_000;
28
+ /** Race sentinel: the resume RPC establishment exceeded {@link DEFAULT_RESUME_RPC_TIMEOUT_MS}. */
29
+ const RESUME_TIMEOUT = 'resume-timeout';
26
30
  /**
27
31
  * Per-`(agentId, threadId)` coordinator that subsumes the pre-#529
28
32
  * `MastraStreamBroker` + `activeRuns` pair into one object owning a single
@@ -65,6 +69,7 @@ export class MastraApprovalCoordinator {
65
69
  memory;
66
70
  requestContext;
67
71
  toolApprovalTimeoutMs;
72
+ resumeRpcTimeoutMs;
68
73
  lookupPolicy;
69
74
  batchApprovals;
70
75
  consumerToolNames;
@@ -170,6 +175,7 @@ export class MastraApprovalCoordinator {
170
175
  this.memory = opts.memory;
171
176
  this.requestContext = opts.requestContext;
172
177
  this.toolApprovalTimeoutMs = opts.toolApprovalTimeoutMs;
178
+ this.resumeRpcTimeoutMs = opts.resumeRpcTimeoutMs ?? DEFAULT_RESUME_RPC_TIMEOUT_MS;
173
179
  this.lookupPolicy = opts.lookupPolicy;
174
180
  this.batchApprovals = opts.batchApprovals ?? false;
175
181
  this.consumerToolNames = opts.consumerToolNames;
@@ -684,42 +690,95 @@ export class MastraApprovalCoordinator {
684
690
  * pushed) or the new `fullStream` to pump next.
685
691
  */
686
692
  async driveResumeReturning(toolCallId, decision) {
693
+ // Kick off the resume RPC. See `resumeRpcTimeoutMs` for why the bound
694
+ // below is on this handshake only, never the streamed work.
695
+ const resume = decision.kind === 'approve'
696
+ ? this.agent.approveToolCall({
697
+ runId: this.currentRunId,
698
+ toolCallId,
699
+ memory: this.memory,
700
+ requestContext: this.requestContext,
701
+ })
702
+ : this.agent.declineToolCall({
703
+ runId: this.currentRunId,
704
+ toolCallId,
705
+ memory: this.memory,
706
+ requestContext: this.requestContext,
707
+ ...(decision.modelMessage ? { reason: decision.modelMessage } : {}),
708
+ });
709
+ // 6c (W-24026787): bound the handshake with a wall-clock timer. See
710
+ // `resumeRpcTimeoutMs` for why this is the only recovery backstop left
711
+ // at this point on plain-REST. Race the RPC against the timer; whichever
712
+ // settles first wins and the timer is always cleared so the win path
713
+ // never leaves a stray timer to double-signal.
714
+ let timer;
715
+ const timeout = new Promise((resolve) => {
716
+ timer = setTimeout(() => resolve(RESUME_TIMEOUT), this.resumeRpcTimeoutMs);
717
+ });
718
+ let raced;
687
719
  try {
688
- const output = decision.kind === 'approve'
689
- ? await this.agent.approveToolCall({
690
- runId: this.currentRunId,
691
- toolCallId,
692
- memory: this.memory,
693
- requestContext: this.requestContext,
694
- })
695
- : await this.agent.declineToolCall({
696
- runId: this.currentRunId,
697
- toolCallId,
698
- memory: this.memory,
699
- requestContext: this.requestContext,
700
- ...(decision.modelMessage ? { reason: decision.modelMessage } : {}),
701
- });
702
- // Post-await guard: abort/dispose may have fired during the
703
- // round-trip. Release the new run if so.
704
- if (this.aborted || this.disposed) {
720
+ raced = await Promise.race([resume, timeout]);
721
+ }
722
+ catch (err) {
723
+ // The resume RPC rejected outright. Route through teardown so
724
+ // siblings in `pendingSuspendIds` settle as auto-resolved rather
725
+ // than silently orphaning.
726
+ const error = err instanceof Error ? err : new Error(String(err));
727
+ this.teardown('pump-error', error, 'pump-error');
728
+ return 'done';
729
+ }
730
+ finally {
731
+ clearTimeout(timer);
732
+ }
733
+ if (raced === RESUME_TIMEOUT) {
734
+ // Release a belatedly-established run on EVERY timeout path — this
735
+ // must sit ABOVE the aborted/disposed/timedOut guard below. This
736
+ // resume timer is a local, not in `approvalTimers`, so `teardown`'s
737
+ // `cancelAllTimers` can't cancel it: a `POST /cancel` during a hung
738
+ // handshake ends the sink but does NOT short-circuit this timer, so
739
+ // it still fires ~resumeRpcTimeoutMs later, hits the guard, and
740
+ // returns — and if the hung RPC then resolves, nothing else would
741
+ // release its run. Attaching here keeps the release on that
742
+ // abort/dispose-then-late-resolve edge (mirrors the abort/dispose
743
+ // post-await guard below). The RPC is never used to drive another
744
+ // stream — the turn is over; a late rejection is a no-op (also
745
+ // already consumed by the race).
746
+ void resume.then((out) => {
705
747
  try {
706
- this.agent.abortRunStream(output.runId);
748
+ this.agent.abortRunStream(out.runId);
707
749
  }
708
750
  catch {
709
751
  /* best-effort */
710
752
  }
753
+ }, () => {
754
+ /* late rejection: already consumed by the race */
755
+ });
756
+ // abort/dispose/another timeout may have already won and ended the
757
+ // sink — if so, nothing more to do (the release above still ran).
758
+ if (this.aborted || this.disposed || this.timedOut)
711
759
  return 'done';
712
- }
713
- this.currentRunId = output.runId;
714
- return output.fullStream;
760
+ this.timedOut = true;
761
+ // Route through the SAME teardown path abort/dispose/approval-timeout
762
+ // use, so the single-terminal invariant holds and the session is
763
+ // released. `teardown`'s sink pushes no-op once the sink has ended,
764
+ // so this can't double-signal a turn that already terminated.
765
+ this.teardown('timeout', new Error(`Tool resume RPC timed out after ${this.resumeRpcTimeoutMs}ms for tool call "${toolCallId}".`), RESUME_TIMEOUT);
766
+ return 'done';
715
767
  }
716
- catch (err) {
717
- // Route through teardown so siblings in `pendingSuspendIds`
718
- // settle as auto-resolved rather than silently orphaning.
719
- const error = err instanceof Error ? err : new Error(String(err));
720
- this.teardown('pump-error', error, 'pump-error');
768
+ const output = raced;
769
+ // Post-await guard: abort/dispose may have fired during the round-trip.
770
+ // Release the new run if so.
771
+ if (this.aborted || this.disposed) {
772
+ try {
773
+ this.agent.abortRunStream(output.runId);
774
+ }
775
+ catch {
776
+ /* best-effort */
777
+ }
721
778
  return 'done';
722
779
  }
780
+ this.currentRunId = output.runId;
781
+ return output.fullStream;
723
782
  }
724
783
  awaitContinuation() {
725
784
  return new Promise((resolve) => {
@@ -2,6 +2,7 @@
2
2
  * Copyright 2026, Salesforce, Inc. All rights reserved.
3
3
  * See LICENSE.txt for license terms.
4
4
  */
5
+ import { CONTEXT_LENGTH_EXCEEDED_CODE, createContextLengthExceededError } from '@salesforce/sfdx-agent-sdk';
5
6
  import { isClassifiedStreamError } from './stream-errors.js';
6
7
  /**
7
8
  * Adapts Mastra `ChunkType` stream events to our {@link ChatEvent} discriminated union.
@@ -153,10 +154,19 @@ export class MastraEventAdapter {
153
154
  }
154
155
  case 'error': {
155
156
  const rawError = chunk.payload.error;
157
+ const code = isClassifiedStreamError(rawError) ? rawError.code : undefined;
158
+ const baseError = rawError instanceof Error ? rawError : new Error(String(rawError));
159
+ // Context-window overflow is the one classified error we upgrade to
160
+ // the SDK's typed `AgentSDKError(CONTEXT_LENGTH_EXCEEDED)` so the
161
+ // in-stream signal matches the pre-flight throw and carries the
162
+ // actionable message; the raw ValidationStreamError rides on `.cause`.
163
+ if (code === CONTEXT_LENGTH_EXCEEDED_CODE) {
164
+ return { type: 'error', error: createContextLengthExceededError(baseError), code };
165
+ }
156
166
  return {
157
167
  type: 'error',
158
- error: rawError instanceof Error ? rawError : new Error(String(rawError)),
159
- ...(isClassifiedStreamError(rawError) ? { code: rawError.code } : {}),
168
+ error: baseError,
169
+ ...(code !== undefined ? { code } : {}),
160
170
  };
161
171
  }
162
172
  case 'tripwire':
@@ -1,6 +1,13 @@
1
1
  export declare const NETWORK_ERROR_CODE: 'network-error';
2
2
  export declare const EMPTY_STREAM_ERROR_CODE: 'empty-stream';
3
- export declare const CONTEXT_OVERFLOW_ERROR_CODE: 'context-window-exceeded';
3
+ /**
4
+ * The overflow `ErrorEvent.code`. Aliased to the SDK-owned
5
+ * {@link CONTEXT_LENGTH_EXCEEDED_CODE} so this harness and the cross-harness
6
+ * contract can never drift on the wire value; the adapter upgrades a
7
+ * ValidationStreamError carrying this code to the typed
8
+ * `AgentSDKError(CONTEXT_LENGTH_EXCEEDED)` before it reaches the consumer.
9
+ */
10
+ export declare const CONTEXT_OVERFLOW_ERROR_CODE: "context-window-exceeded";
4
11
  export declare const VALIDATION_ERROR_CODE: 'validation-error';
5
12
  export declare const GATEWAY_RESPONSE_ERROR_CODE: 'gateway-error';
6
13
  /**
@@ -2,9 +2,17 @@
2
2
  * Copyright 2026, Salesforce, Inc. All rights reserved.
3
3
  * See LICENSE.txt for license terms.
4
4
  */
5
+ import { CONTEXT_LENGTH_EXCEEDED_CODE, isContextWindowOverflowMessage } from '@salesforce/sfdx-agent-sdk';
5
6
  export const NETWORK_ERROR_CODE = 'network-error';
6
7
  export const EMPTY_STREAM_ERROR_CODE = 'empty-stream';
7
- export const CONTEXT_OVERFLOW_ERROR_CODE = 'context-window-exceeded';
8
+ /**
9
+ * The overflow `ErrorEvent.code`. Aliased to the SDK-owned
10
+ * {@link CONTEXT_LENGTH_EXCEEDED_CODE} so this harness and the cross-harness
11
+ * contract can never drift on the wire value; the adapter upgrades a
12
+ * ValidationStreamError carrying this code to the typed
13
+ * `AgentSDKError(CONTEXT_LENGTH_EXCEEDED)` before it reaches the consumer.
14
+ */
15
+ export const CONTEXT_OVERFLOW_ERROR_CODE = CONTEXT_LENGTH_EXCEEDED_CODE;
8
16
  export const VALIDATION_ERROR_CODE = 'validation-error';
9
17
  export const GATEWAY_RESPONSE_ERROR_CODE = 'gateway-error';
10
18
  /**
@@ -109,12 +117,6 @@ export function isStreamTransportError(error) {
109
117
  }
110
118
  return false;
111
119
  }
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
120
  /**
119
121
  * Recognizes the Bedrock `ValidationException` shape (8d), walking the `cause`
120
122
  * chain and duck-typing the several ways `@anthropic-ai/bedrock-sdk` /
@@ -131,7 +133,7 @@ export function recognizeBedrockValidationError(error) {
131
133
  seen.add(current);
132
134
  const message = extractValidationExceptionMessage(current);
133
135
  if (message !== undefined) {
134
- const code = CONTEXT_OVERFLOW_PATTERNS.test(message) ? CONTEXT_OVERFLOW_ERROR_CODE : VALIDATION_ERROR_CODE;
136
+ const code = isContextWindowOverflowMessage(message) ? CONTEXT_OVERFLOW_ERROR_CODE : VALIDATION_ERROR_CODE;
135
137
  return new ValidationStreamError(message, code);
136
138
  }
137
139
  current = current.cause;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/sfdx-agent-harness-mastra",
3
- "version": "0.65.0",
3
+ "version": "0.67.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.66.0"
45
+ "@salesforce/sfdx-agent-sdk": "0.68.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.66.0",
50
+ "@salesforce/sfdx-agent-sdk": "0.68.0",
51
51
  "@types/node": "^22.20.1",
52
52
  "@vitest/coverage-istanbul": "^4.1.10",
53
53
  "@vitest/eslint-plugin": "^1.6.27",