@salesforce/sfdx-agent-harness-mastra 0.17.0 → 0.18.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,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.18.0] - 2026-06-11
7
+
8
+ ### Fixes
9
+ - **harness-mastra**: drive one resume per pending approval for parallel batches ([#587](https://github.com/forcedotcom/agentic-dx/pull/587))
10
+
6
11
  ## [0.17.0] - 2026-06-09
7
12
 
8
13
  ### Fixes
@@ -143,12 +143,11 @@ export declare class MastraApprovalCoordinator {
143
143
  */
144
144
  private currentRunId;
145
145
  /**
146
- * The toolCallId Mastra is currently suspended on (after the natural
147
- * `tool-call-approval` chunk). `null` when no suspension. The
148
- * coordinator's pump loop reads this when the current `fullStream`
149
- * closes to decide whether to drive a resume.
146
+ * ToolCallIds Mastra is currently suspended on. Mastra retains a separate
147
+ * workflow snapshot step per id, so the pump must drive one resume per
148
+ * pending id with `{ toolCallId }` to disambiguate (#586).
150
149
  */
151
- private currentSuspendId;
150
+ private readonly pendingSuspendIds;
152
151
  /** Consumer-tool: bound parkers, keyed by `toolCallId`. `submitToolResult` resolves these. */
153
152
  private readonly pendingByToolCallId;
154
153
  /**
@@ -5,6 +5,13 @@
5
5
  import { AgentSDKError, AgentSDKErrorType, } from '@salesforce/sfdx-agent-sdk';
6
6
  import { GenSink } from '@salesforce/sfdx-agent-sdk/harness';
7
7
  import { MastraEventAdapter } from './mastra-event-adapter.js';
8
+ function pickIdWithDecision(pendingIds, decisions) {
9
+ for (const id of pendingIds) {
10
+ if (decisions.has(id))
11
+ return id;
12
+ }
13
+ return undefined;
14
+ }
8
15
  /**
9
16
  * Per-`(agentId, threadId)` coordinator that subsumes the pre-#529
10
17
  * `MastraStreamBroker` + `activeRuns` pair into one object owning a single
@@ -72,12 +79,11 @@ export class MastraApprovalCoordinator {
72
79
  */
73
80
  currentRunId = '';
74
81
  /**
75
- * The toolCallId Mastra is currently suspended on (after the natural
76
- * `tool-call-approval` chunk). `null` when no suspension. The
77
- * coordinator's pump loop reads this when the current `fullStream`
78
- * closes to decide whether to drive a resume.
82
+ * ToolCallIds Mastra is currently suspended on. Mastra retains a separate
83
+ * workflow snapshot step per id, so the pump must drive one resume per
84
+ * pending id with `{ toolCallId }` to disambiguate (#586).
79
85
  */
80
- currentSuspendId = null;
86
+ pendingSuspendIds = new Set();
81
87
  /** Consumer-tool: bound parkers, keyed by `toolCallId`. `submitToolResult` resolves these. */
82
88
  pendingByToolCallId = new Map();
83
89
  /**
@@ -210,7 +216,7 @@ export class MastraApprovalCoordinator {
210
216
  this.initial.push({ type: 'finish', finishReason: 'error' });
211
217
  this.initial.end();
212
218
  }
213
- this.currentSuspendId = null;
219
+ this.pendingSuspendIds.clear();
214
220
  this.runTeardownCallbacks();
215
221
  }
216
222
  // ───────────────────────── settle methods ───────────────────────────────
@@ -233,14 +239,9 @@ export class MastraApprovalCoordinator {
233
239
  this.consumerSettled.add(toolCallId);
234
240
  this.cancelTimer(toolCallId);
235
241
  this.decisions.set(toolCallId, decision);
236
- // The pump loop is the single owner of `agent.approveToolCall(...)`
237
- // / `declineToolCall(...)` invocation. recordDecision buffers the
238
- // decision; the pump's outer loop reads `decisions.get(suspendId)`
239
- // when it sees the matching suspension and drives the resume. If
240
- // the pump is currently parked on `awaitContinuation` (saw the
241
- // suspension first, decision arrived second), wake it so it
242
- // re-checks `decisions`.
243
- if (this.continuationResolver && this.currentSuspendId === toolCallId) {
242
+ // Wake the pump if it's parked on `awaitContinuation` waiting for
243
+ // a decision on a pending id.
244
+ if (this.continuationResolver && this.pendingSuspendIds.has(toolCallId)) {
244
245
  const resolve = this.continuationResolver;
245
246
  this.continuationResolver = undefined;
246
247
  resolve('continued');
@@ -330,33 +331,29 @@ export class MastraApprovalCoordinator {
330
331
  }
331
332
  if (this.aborted || this.disposed || this.timedOut)
332
333
  return;
333
- // outcome === 'suspended': Mastra suspended after a
334
- // `tool-call-approval` chunk. `currentSuspendId` carries
335
- // the id Mastra is suspended on. Either the consumer
336
- // already settled (decision buffered) and we drive the
337
- // resume now, or we park until a settle arrives.
338
- const suspendId = this.currentSuspendId;
339
- if (suspendId === null) {
340
- // Defensive: stream ended without a suspension. Treat
341
- // as terminal.
334
+ // outcome === 'suspended': drive one resume per outer
335
+ // iteration. For parallel batches, all but the last resume
336
+ // returns a stream that closes silently while still
337
+ // suspended — the pump re-enters here to drive the next id.
338
+ if (this.pendingSuspendIds.size === 0) {
342
339
  if (!this.initial.isEnded())
343
340
  this.initial.end();
344
341
  return;
345
342
  }
346
- let buffered = this.decisions.get(suspendId);
347
- if (buffered === undefined) {
348
- // Park for a decision (or teardown).
343
+ let nextId = pickIdWithDecision(this.pendingSuspendIds, this.decisions);
344
+ if (nextId === undefined) {
349
345
  const cont = await this.awaitContinuation();
350
346
  if (cont === 'done')
351
347
  return;
352
- // Re-read `decisions` post-wake; recordDecision sets it
353
- // before signaling, so we must see it here.
354
- buffered = this.decisions.get(suspendId);
355
- if (buffered === undefined)
348
+ nextId = pickIdWithDecision(this.pendingSuspendIds, this.decisions);
349
+ if (nextId === undefined)
356
350
  return;
357
351
  }
358
- this.currentSuspendId = null;
359
- const next = await this.driveResumeReturning(suspendId, buffered);
352
+ const decision = this.decisions.get(nextId);
353
+ if (decision === undefined)
354
+ return;
355
+ this.pendingSuspendIds.delete(nextId);
356
+ const next = await this.driveResumeReturning(nextId, decision);
360
357
  if (next === 'done')
361
358
  return;
362
359
  currentStream = next;
@@ -434,22 +431,21 @@ export class MastraApprovalCoordinator {
434
431
  continue;
435
432
  }
436
433
  if (event.type === 'tool-approval-request') {
437
- // Mastra's natural `tool-call-approval` chunk → ChatEvent.
438
- // Mastra has suspended on this toolCallId. Decide whether
439
- // to surface a `tool-approval-request` to the consumer or
440
- // auto-approve silently.
434
+ // Parallel batches emit one chunk per suspension before
435
+ // the stream closes; accumulate every id and let the
436
+ // read loop continue to the natural close.
441
437
  const tc = event.toolCall;
442
438
  this.toolCallArgs.set(tc.toolCallId, tc.args);
443
- this.currentSuspendId = tc.toolCallId;
439
+ this.pendingSuspendIds.add(tc.toolCallId);
444
440
  sawSuspension = true;
445
441
  if (this.consumerToolNames.has(tc.toolName)) {
446
442
  // Consumer tools bypass the gate per the
447
443
  // StreamOptions.requireToolApproval JSDoc. Auto-
448
444
  // approve silently — no ChatEvent surfaces, and the
449
- // outer pump loop drives `agent.approveToolCall`
450
- // when it picks up the buffered decision. After
451
- // Mastra resumes, the bridge `execute` parker runs
452
- // and `submitToolResult` settles it — same shape as
445
+ // pump loop drives `agent.approveToolCall` when it
446
+ // picks up the buffered decision. After Mastra
447
+ // resumes, the bridge `execute` parker runs and
448
+ // `submitToolResult` settles it — same shape as
453
449
  // non-approval-mode consumer-tool flow.
454
450
  this.settledByCoordinator.add(tc.toolCallId);
455
451
  this.decisions.set(tc.toolCallId, { kind: 'approve' });
@@ -459,14 +455,7 @@ export class MastraApprovalCoordinator {
459
455
  // pre-emit in tool-call routing).
460
456
  this.emitApprovalRequest(tc.toolCallId, tc.toolName, tc.args);
461
457
  }
462
- // Drain the rest of the stream so Mastra's workflow
463
- // snapshot persists before we attempt a resume.
464
- while (true) {
465
- const { done: d } = await reader.read();
466
- if (d)
467
- break;
468
- }
469
- return 'suspended';
458
+ continue;
470
459
  }
471
460
  this.routeEnriched(event);
472
461
  }
@@ -490,17 +479,13 @@ export class MastraApprovalCoordinator {
490
479
  /* best-effort: lock may already be released after a thrown read */
491
480
  }
492
481
  }
493
- // Stream closed naturally without a `finish` chunk and without a
494
- // suspension. This happens when:
495
- // (a) abort/dispose/timeout fired and Mastra closed the stream
496
- // silently (no chunk emitted).
497
- // (b) consumer-tool flow: the tool's parker resolved, Mastra
498
- // continued the same fullStream, and the next assistant turn
499
- // ran to terminal `finish` — but in that case `finish` would
500
- // have arrived in the loop above. So (b) shouldn't reach here.
501
- // Treat as terminal: sink is either ended already (teardown) or
502
- // we must end it now.
503
- if (!sawTerminal && !sawSuspension && !this.initial.isEnded()) {
482
+ // Stream closed without `finish`/`error`. If any suspension is
483
+ // pending (just observed, or carried over from a prior non-final
484
+ // resume that closed silently), defer to the pump loop's drive.
485
+ if (sawSuspension || this.pendingSuspendIds.size > 0) {
486
+ return 'suspended';
487
+ }
488
+ if (!sawTerminal && !this.initial.isEnded()) {
504
489
  this.initial.end();
505
490
  }
506
491
  return 'terminal';
@@ -540,10 +525,10 @@ export class MastraApprovalCoordinator {
540
525
  return output.fullStream;
541
526
  }
542
527
  catch (err) {
528
+ // Route through teardown so siblings in `pendingSuspendIds`
529
+ // settle as auto-resolved rather than silently orphaning.
543
530
  const error = err instanceof Error ? err : new Error(String(err));
544
- this.initial.push({ type: 'error', error });
545
- this.initial.push({ type: 'finish', finishReason: 'error' });
546
- this.initial.end();
531
+ this.teardown('pump-error', error, 'pump-error');
547
532
  return 'done';
548
533
  }
549
534
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/sfdx-agent-harness-mastra",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Mastra-backed AgentHarness implementation for @salesforce/sfdx-agent-sdk",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -38,11 +38,11 @@
38
38
  "@salesforce/llm-gateway-sdk": "0.14.0"
39
39
  },
40
40
  "peerDependencies": {
41
- "@salesforce/sfdx-agent-sdk": "0.18.0"
41
+ "@salesforce/sfdx-agent-sdk": "0.19.0"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@eslint/js": "^10.0.1",
45
- "@salesforce/sfdx-agent-sdk": "0.18.0",
45
+ "@salesforce/sfdx-agent-sdk": "0.19.0",
46
46
  "@types/node": "^22.19.20",
47
47
  "@vitest/coverage-istanbul": "^4.1.8",
48
48
  "@vitest/eslint-plugin": "^1.6.19",