@salesforce/sfdx-agent-harness-mastra 0.8.0 → 0.9.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/README.md CHANGED
@@ -24,6 +24,24 @@ const manager = await createAgentManager('/path/to/storage', new MastraHarnessFa
24
24
  See the [`@salesforce/sfdx-agent-sdk` README](../sfdx-agent-sdk/README.md) for the full consumer-facing API. For
25
25
  internal architecture, see [ARCHITECTURE.md](ARCHITECTURE.md).
26
26
 
27
+ ### Removing the storage directory on Windows
28
+
29
+ `manager.shutdown()` closes the libsql client (forcedotcom/agentic-dx#429), but the underlying SQLite/WAL handle release
30
+ on Windows is asynchronous at the OS level. After `shutdown()` returns, Windows can take anywhere from ~60 ms to several
31
+ seconds to actually release the file lock — so a naïve `fs.rm(storageRoot)` immediately afterward will fail with
32
+ `EBUSY`. Pass `maxRetries` and `retryDelay` so Node retries until the OS lets go:
33
+
34
+ ```ts
35
+ import { rm } from 'node:fs/promises';
36
+
37
+ await manager.shutdown();
38
+ await rm(storageRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
39
+ ```
40
+
41
+ Linux and macOS release the handle eagerly; the retry options are no-ops on those platforms. Tracking upstream at
42
+ [mastra-ai/mastra#17285](https://github.com/mastra-ai/mastra/issues/17285) — when `LibSQLStore` exposes a public
43
+ `close()` that includes a WAL checkpoint, this delay should shrink or disappear.
44
+
27
45
  ## Public API
28
46
 
29
47
  ### Core
@@ -2,7 +2,7 @@ import { Agent } from '@mastra/core/agent';
2
2
  import { SkillSearchProcessor, ToolSearchProcessor } from '@mastra/core/processors';
3
3
  import type { ChunkType } from '@mastra/core/stream';
4
4
  import { MCPClient } from '@mastra/mcp';
5
- import { type AgentHarness, type WithAgentConfig, type ChatStreamResult, type HarnessAgentConfig, type MCPConfiguration, type McpServerInfo, type Message, type StreamOptions, type TelemetryEventCallback, type ToolResultInfo } from '@salesforce/sfdx-agent-sdk';
5
+ import { McpServerStatus, type AgentHarness, type WithAgentConfig, type ChatStreamResult, type HarnessAgentConfig, type MCPConfiguration, type McpServerErrorDetail, type McpServerInfo, type Message, type StreamOptions, type TelemetryEventCallback, type ToolResultInfo } from '@salesforce/sfdx-agent-sdk';
6
6
  import type { MastraModelConfigFactory } from './model-config-factory.js';
7
7
  import { type MastraAgentConfig, type SkillSearchHandle, type ToolSearchHandle } from './tool-search.js';
8
8
  import type { LLMGatewayClient } from '@salesforce/llm-gateway-sdk';
@@ -20,10 +20,19 @@ import { Workspace } from '@mastra/core/workspace';
20
20
  * if the consumer breaks out of the event stream early (common in
21
21
  * approval UIs), we must finish reading the stream to ensure the
22
22
  * snapshot is persisted before we attempt to resume.
23
+ *
24
+ * `disposeAbortListener` is invoked whenever the entry is replaced or
25
+ * removed so the per-run abort listener (installed by `stream()` against
26
+ * the caller's `streamOptions.abortSignal`) cannot fire on a run that is
27
+ * no longer the active one for the thread. Mirrors the SDK invariant
28
+ * "every chat turn ends with exactly one terminal `FinishEvent`" by
29
+ * ensuring we don't accidentally abort a continuation run that
30
+ * `approveToolCall` / `declineToolCall` has already authorized.
23
31
  */
24
32
  interface ActiveRun {
25
33
  runId: string;
26
34
  fullStream: ReadableStream<ChunkType>;
35
+ disposeAbortListener?: () => void;
27
36
  }
28
37
  /**
29
38
  * Per-server MCP state. Each enabled server gets its own MCPClient and
@@ -37,8 +46,27 @@ interface ActiveRun {
37
46
  */
38
47
  interface McpServerState {
39
48
  client: MCPClient;
49
+ /**
50
+ * Last `McpServerStatus` value emitted (or seeded to `Connecting` at
51
+ * construction). Updated synchronously by `emitMcpStatusChange()` so a
52
+ * follow-up `getMcpServerInfo()` call observes the world the most recent
53
+ * `mcp-server-status-changed` event described.
54
+ */
55
+ currentStatus: McpServerStatus;
40
56
  epoch: number;
57
+ /**
58
+ * Sanitized human-readable error message. V8 stack frames and file paths
59
+ * have been stripped — safe for UI banners and telemetry tags. The full
60
+ * error (with `.stack`) is preserved on the `mcp-server-discovery-failed`
61
+ * event's `error` field.
62
+ */
41
63
  error?: string;
64
+ /**
65
+ * Structured failure projection. Mirrors the SDK's `McpServerErrorDetail`
66
+ * shape so consumers can route on category / code / retriable. Populated
67
+ * alongside `error`.
68
+ */
69
+ errorDetail?: McpServerErrorDetail;
42
70
  ready: Promise<void>;
43
71
  settled: boolean;
44
72
  tools: ToolsInput;
@@ -119,10 +147,18 @@ export declare class MastraHarness implements MastraAgentHarness {
119
147
  * would mix process state.
120
148
  */
121
149
  protected readonly activeAgents: Map<string, MastraAgentState>;
150
+ private readonly libsqlClient;
122
151
  private readonly storage;
123
152
  private readonly memory;
124
153
  private readonly mastra;
125
154
  private readonly buses;
155
+ /**
156
+ * Tracks the in-flight storage init so {@link shutdown} can settle it
157
+ * before closing the libsql client. Without this, Mastra's lazy storage
158
+ * init can fire after `client.close()` and surface as an unhandled
159
+ * `CLIENT_CLOSED` rejection (#429 follow-up).
160
+ */
161
+ private readonly storageReady;
126
162
  /**
127
163
  * Mastra-specific extension surface, typed off `MastraAgentHarness`. The
128
164
  * SDK never reads this; consumers reach through `manager.extensions.mastra`.
@@ -134,6 +170,9 @@ export declare class MastraHarness implements MastraAgentHarness {
134
170
  * - MUST disconnect the mcpClient for every active agent before clearing state.
135
171
  * - MUST clear the `activeAgents` map.
136
172
  * - MUST shut down the global `this.mastra` orchestrator.
173
+ * - MUST close the libsql client so the underlying SQLite handle is released
174
+ * (Windows blocks fs.rm of the storage dir while the WAL/shm files are open;
175
+ * see forcedotcom/agentic-dx#429).
137
176
  */
138
177
  shutdown(): Promise<void>;
139
178
  onTelemetry(callback: TelemetryEventCallback): Unsubscribe;
@@ -250,6 +289,10 @@ export declare class MastraHarness implements MastraAgentHarness {
250
289
  * - MUST return a `ChatStreamResult` with `textStream` and `eventStream`.
251
290
  * - On pre-stream rejection from `agent.stream()`, MUST re-throw without emitting telemetry —
252
291
  * the SDK's chat-session boundary catches the rejection and emits `chat-stream-error`.
292
+ * - MUST install a per-run abort listener on `options.abortSignal` so that aborting
293
+ * while a tool-approval is pending tears down the suspended Mastra run and clears
294
+ * the thread's `ActiveRun` entry. See ARCHITECTURE.md → "Tool Approval Flow → Abort
295
+ * during pending approval (#409)".
253
296
  */
254
297
  stream(agentId: string, threadId: string, message: string, options?: StreamOptions): Promise<ChatStreamResult>;
255
298
  /**
@@ -343,6 +386,25 @@ export declare class MastraHarness implements MastraAgentHarness {
343
386
  */
344
387
  private splitIntoChunks;
345
388
  private getAgentStateOrThrow;
389
+ /**
390
+ * Synchronously flips `serverState.currentStatus` (no-op when the next
391
+ * status equals the current) and returns a closure that, when invoked,
392
+ * emits the corresponding `mcp-server-status-changed` event. Returns
393
+ * `undefined` when the status didn't change — the caller has nothing
394
+ * to emit.
395
+ *
396
+ * Splitting the state flip from the emit lets callers update internal
397
+ * state synchronously alongside `settled` (so a synchronous listener
398
+ * reading `getMcpServerInfo()` from inside the discovery telemetry
399
+ * callback observes the post-settle world per issue #373) while still
400
+ * deferring the status-change emit until after the discovery telemetry
401
+ * has fired.
402
+ *
403
+ * The returned closure swallows `DISPOSED` errors the same way the
404
+ * discovery emit sites do: background discovery callbacks can resolve
405
+ * after `shutdown()` has disposed the buses.
406
+ */
407
+ private prepareMcpStatusChange;
346
408
  /**
347
409
  * Runs MCP discovery for one server: emits the
348
410
  * `mcp-server-discovery-started` / `…-completed` / `…-failed` telemetry
@@ -358,11 +420,43 @@ export declare class MastraHarness implements MastraAgentHarness {
358
420
  * Errors observed during discovery are recorded on the entry's `error`
359
421
  * field; the returned promise resolves either way (it only rejects when
360
422
  * an aborted operation throws after we have already cleaned up). The
361
- * `settled` flag is pinned by `.finally()` so `getMcpServerInfo()` can
362
- * tell `Connecting` from `Connected`/`Error`.
423
+ * `settled` flag is pinned BEFORE the terminal telemetry emit so a
424
+ * synchronous listener calling `getMcpServerInfo()` from inside the
425
+ * `mcp-server-discovery-{completed,failed}` callback observes the same
426
+ * world the event described — see issue #373.
363
427
  */
364
428
  private runMcpDiscovery;
365
429
  private toChatStreamResult;
430
+ /**
431
+ * Replace (or set) the active run for a thread, disposing any prior
432
+ * entry's per-run abort listener so the prior signal cannot fire on the
433
+ * new run.
434
+ */
435
+ private replaceActiveRun;
436
+ /**
437
+ * Remove the active run for a thread, disposing its per-run abort listener
438
+ * if present. Safe to call when no entry exists.
439
+ */
440
+ private removeActiveRun;
441
+ /**
442
+ * Wire `abortSignal.abort()` to tear down the suspended Mastra run for
443
+ * `threadId`. Returns a disposer that removes the listener; callers must
444
+ * invoke it whenever the entry is replaced or removed so a stale signal
445
+ * cannot abort an unrelated continuation run.
446
+ *
447
+ * Identity guard: the listener checks `agentState.activeRuns.get(threadId) === run`
448
+ * before calling `abortRunStream`, so a defensive race with another
449
+ * `stream()` call on the same thread cannot double-abort.
450
+ *
451
+ * Closes #409 — without this listener, aborting while a tool-approval is
452
+ * pending leaves Mastra's prepared run alive and `activeRuns[threadId]`
453
+ * pointing at a runId the consumer can no longer settle, so subsequent
454
+ * `approveToolCall` reaches Mastra and surfaces a confusing
455
+ * "could not find a suspended run" error instead of the SDK-level
456
+ * "no active run found" guard.
457
+ */
458
+ private installPerRunAbortListener;
459
+ private handlePerRunAbort;
366
460
  private getMemoryOrThrow;
367
461
  private agentResourceId;
368
462
  /**
@@ -10,6 +10,7 @@ import { Memory } from '@mastra/memory';
10
10
  import { SkillSearchProcessor, ToolSearchProcessor } from '@mastra/core/processors';
11
11
  import { MASTRA_RESOURCE_ID_KEY, MASTRA_THREAD_ID_KEY, RequestContext } from '@mastra/core/request-context';
12
12
  import { LibSQLStore } from '@mastra/libsql';
13
+ import { createClient } from '@libsql/client';
13
14
  import { MCPClient } from '@mastra/mcp';
14
15
  import { AgentSDKError, AgentSDKErrorType, DEFAULT_MAX_STEPS, HarnessBusOwner, McpServerStatus, } from '@salesforce/sfdx-agent-sdk';
15
16
  import { MastraEventAdapter } from './mastra-event-adapter.js';
@@ -17,6 +18,7 @@ import { mapMastraToMessage, mapMessageToMastra, partToTranscriptText } from './
17
18
  import { isStandardSchemaWithJSON, standardSchemaToJSONSchema } from '@mastra/schema-compat';
18
19
  import { mapToolDefinitionsToMastraTools } from './mastra-tool-mapper.js';
19
20
  import { hasEnabledServers, mapToMastraMCPServers } from './mcp-config-mapper.js';
21
+ import { buildMcpErrorDetail, sanitizeMcpErrorMessage } from './mcp-error-classifier.js';
20
22
  import { DefaultMastraModelConfigFactory } from './default-model-config-factory.js';
21
23
  import { partitionPool, } from './tool-search.js';
22
24
  import { buildSummaryPrompt, Clock, getErrorMessage, RealClock, splitFrontmatterAndBody, UUIDGenerator, } from '@salesforce/agentic-common';
@@ -311,10 +313,18 @@ export class MastraHarness {
311
313
  * would mix process state.
312
314
  */
313
315
  activeAgents = new Map();
316
+ libsqlClient;
314
317
  storage;
315
318
  memory;
316
319
  mastra;
317
320
  buses = new HarnessBusOwner();
321
+ /**
322
+ * Tracks the in-flight storage init so {@link shutdown} can settle it
323
+ * before closing the libsql client. Without this, Mastra's lazy storage
324
+ * init can fire after `client.close()` and surface as an unhandled
325
+ * `CLIENT_CLOSED` rejection (#429 follow-up).
326
+ */
327
+ storageReady;
318
328
  /**
319
329
  * Mastra-specific extension surface, typed off `MastraAgentHarness`. The
320
330
  * SDK never reads this; consumers reach through `manager.extensions.mastra`.
@@ -338,9 +348,24 @@ export class MastraHarness {
338
348
  this.modelConfigFactory = modelConfigFactory;
339
349
  this.clock = clock;
340
350
  this.idGenerator = idGenerator;
351
+ // Construct the libsql Client ourselves and inject it into LibSQLStore
352
+ // via the `{ client }` constructor form. We keep our own reference so
353
+ // shutdown() can call client.close() — Mastra's shutdown() does not
354
+ // close the storage, and LibSQLStore declares `client` as private, so
355
+ // we cannot reach in. Without an explicit close, the SQLite WAL/shm
356
+ // handles stay open on Windows and block fs.rm of the storage dir
357
+ // (forcedotcom/agentic-dx#429).
358
+ //
359
+ // Upstream tracking: https://github.com/mastra-ai/mastra/issues/17285
360
+ // — when LibSQLStore exposes a public close(), revert to the simpler
361
+ // `new LibSQLStore({ id, url })` form and call store.close() in
362
+ // shutdown().
363
+ this.libsqlClient = createClient({
364
+ url: `file:${this.storageRootFolder}/mastra.db`,
365
+ });
341
366
  this.storage = new LibSQLStore({
342
367
  id: 'mastra-storage',
343
- url: `file:${this.storageRootFolder}/mastra.db`,
368
+ client: this.libsqlClient,
344
369
  });
345
370
  this.memory = new Memory({
346
371
  storage: this.storage,
@@ -357,15 +382,30 @@ export class MastraHarness {
357
382
  storage: this.storage,
358
383
  memory: { shared: this.memory },
359
384
  });
385
+ // Eagerly run storage init so Mastra's augment-with-init proxy caches
386
+ // the result. Without this, any lazy storage call deferred until after
387
+ // shutdown() (e.g. background tool/agent registration paths in Mastra)
388
+ // races our libsqlClient.close() and surfaces as an unhandled
389
+ // CLIENT_CLOSED rejection. We retain the promise so shutdown() can
390
+ // settle it before closing the client (a swallowed rejection here is
391
+ // fine — Mastra retains its own init proxy and surfaces real init
392
+ // errors at the next storage call site).
393
+ this.storageReady = this.storage.init().catch(() => undefined);
360
394
  }
361
395
  /**
362
396
  * @requirements
363
397
  * - MUST disconnect the mcpClient for every active agent before clearing state.
364
398
  * - MUST clear the `activeAgents` map.
365
399
  * - MUST shut down the global `this.mastra` orchestrator.
400
+ * - MUST close the libsql client so the underlying SQLite handle is released
401
+ * (Windows blocks fs.rm of the storage dir while the WAL/shm files are open;
402
+ * see forcedotcom/agentic-dx#429).
366
403
  */
367
404
  async shutdown() {
368
405
  await Promise.all([...this.activeAgents.values()].map(async (state) => {
406
+ for (const run of state.activeRuns.values())
407
+ run.disposeAbortListener?.();
408
+ state.activeRuns.clear();
369
409
  await Promise.allSettled(Array.from(state.mcpServers.values()).map(async (s) => {
370
410
  await s.ready.catch(() => undefined);
371
411
  await s.client.disconnect();
@@ -374,6 +414,10 @@ export class MastraHarness {
374
414
  }));
375
415
  this.activeAgents.clear();
376
416
  await this.mastra?.shutdown();
417
+ // Settle the constructor's eager init before closing — see the
418
+ // `storageReady` field comment for the why.
419
+ await this.storageReady;
420
+ this.libsqlClient.close();
377
421
  this.buses.dispose();
378
422
  }
379
423
  onTelemetry(callback) {
@@ -424,6 +468,7 @@ export class MastraHarness {
424
468
  // after the synchronous `set` and `ready` assignment complete.
425
469
  const entry = {
426
470
  client,
471
+ currentStatus: McpServerStatus.Connecting,
427
472
  epoch: 0,
428
473
  ready: Promise.resolve(),
429
474
  settled: false,
@@ -556,6 +601,9 @@ export class MastraHarness {
556
601
  */
557
602
  async destroyAgent(agentId) {
558
603
  const state = this.getAgentStateOrThrow(agentId);
604
+ for (const run of state.activeRuns.values())
605
+ run.disposeAbortListener?.();
606
+ state.activeRuns.clear();
559
607
  await Promise.allSettled(Array.from(state.mcpServers.values()).map(async (s) => {
560
608
  await s.ready.catch(() => undefined);
561
609
  await s.client.disconnect();
@@ -576,19 +624,35 @@ export class MastraHarness {
576
624
  const state = this.getAgentStateOrThrow(agentId);
577
625
  const results = [];
578
626
  for (const [name, serverState] of state.mcpServers) {
579
- if (!serverState.settled) {
580
- results.push({ name, status: McpServerStatus.Connecting, tools: [] });
581
- }
582
- else if (serverState.error) {
583
- results.push({ error: serverState.error, name, status: McpServerStatus.Error, tools: [] });
584
- }
585
- else {
627
+ // `currentStatus` is the source of truth: it tracks `Reconnecting`
628
+ // (transient state during a reconnect on a previously-Connected
629
+ // server) which the `settled`/`error` pair cannot represent.
630
+ // `settled` still gates whether tools are visible — Mastra's
631
+ // discovery has resolved at most once per epoch.
632
+ const status = serverState.currentStatus;
633
+ if (status === McpServerStatus.Connected && serverState.settled) {
586
634
  results.push({
587
635
  name,
588
636
  status: McpServerStatus.Connected,
589
637
  tools: mapMastraToolsToMcpToolInfo(serverState.tools, this.buses.getLogBus()),
590
638
  });
591
639
  }
640
+ else if (status === McpServerStatus.Error && serverState.settled) {
641
+ const info = {
642
+ error: serverState.error,
643
+ name,
644
+ status: McpServerStatus.Error,
645
+ tools: [],
646
+ };
647
+ if (serverState.errorDetail)
648
+ info.errorDetail = serverState.errorDetail;
649
+ results.push(info);
650
+ }
651
+ else {
652
+ // Connecting (initial), Reconnecting (mid-cycle), or any
653
+ // status whose discovery has not yet settled.
654
+ results.push({ name, status, tools: [] });
655
+ }
592
656
  }
593
657
  if (state.mcpConfig) {
594
658
  for (const [name, serverConfig] of Object.entries(state.mcpConfig)) {
@@ -627,6 +691,17 @@ export class MastraHarness {
627
691
  serverState.error = undefined;
628
692
  serverState.tools = {};
629
693
  serverState.settled = false;
694
+ // Status transition before transport work begins. A reconnect on a
695
+ // previously-`Connected` server lands in `Reconnecting` (transport is
696
+ // being cycled); reconnects from `Error` are a recovery path and use
697
+ // the regular `Connecting` placeholder so the (prev,next) pair on
698
+ // success reads `Connecting → Connected` like an initial discovery.
699
+ // Concurrent reconnects (already `Reconnecting`) coalesce via the
700
+ // emit-helper's same-status no-op.
701
+ const transientStatus = serverState.currentStatus === McpServerStatus.Connected
702
+ ? McpServerStatus.Reconnecting
703
+ : McpServerStatus.Connecting;
704
+ this.prepareMcpStatusChange(agentId, serverName, serverState, transientStatus)?.();
630
705
  // Emit `started` BEFORE awaiting the transport reconnect so subscribers
631
706
  // see started → (completed | failed) timestamps in chronological order.
632
707
  // The previous shape had `started` emitted from the catch block AFTER
@@ -650,9 +725,12 @@ export class MastraHarness {
650
725
  // Drop telemetry + state writes if a newer epoch has superseded us.
651
726
  if (state.mcpServers.get(serverName)?.epoch !== epoch)
652
727
  return;
653
- const message = err instanceof Error ? err.message : String(err);
728
+ const message = sanitizeMcpErrorMessage(err);
729
+ const errorDetail = buildMcpErrorDetail(err);
654
730
  serverState.error = message;
731
+ serverState.errorDetail = errorDetail;
655
732
  serverState.settled = true;
733
+ const emitStatusChange = this.prepareMcpStatusChange(agentId, serverName, serverState, McpServerStatus.Error, message);
656
734
  this.buses.getLogBus()?.warn('MCP server reconnect failed', {
657
735
  serverName,
658
736
  error: message,
@@ -665,12 +743,14 @@ export class MastraHarness {
665
743
  serverName,
666
744
  durationMs: this.clock.now().getTime() - startTime,
667
745
  error: err instanceof Error ? err : new Error(message),
746
+ errorDetail,
668
747
  });
669
748
  }
670
749
  catch (emitErr) {
671
750
  if (!isDisposedError(emitErr))
672
751
  throw emitErr;
673
752
  }
753
+ emitStatusChange?.();
674
754
  return;
675
755
  }
676
756
  // Pass the existing startTime so runMcpDiscovery does NOT emit its own
@@ -703,7 +783,7 @@ export class MastraHarness {
703
783
  * - MUST remove any active run tracked for this thread to prevent stale references.
704
784
  */
705
785
  async destroyThread(agentId, threadId) {
706
- this.getAgentStateOrThrow(agentId).activeRuns.delete(threadId);
786
+ this.removeActiveRun(this.getAgentStateOrThrow(agentId), threadId);
707
787
  const memory = await this.getMemoryOrThrow(agentId);
708
788
  await memory.deleteThread(threadId);
709
789
  }
@@ -830,6 +910,10 @@ export class MastraHarness {
830
910
  * - MUST return a `ChatStreamResult` with `textStream` and `eventStream`.
831
911
  * - On pre-stream rejection from `agent.stream()`, MUST re-throw without emitting telemetry —
832
912
  * the SDK's chat-session boundary catches the rejection and emits `chat-stream-error`.
913
+ * - MUST install a per-run abort listener on `options.abortSignal` so that aborting
914
+ * while a tool-approval is pending tears down the suspended Mastra run and clears
915
+ * the thread's `ActiveRun` entry. See ARCHITECTURE.md → "Tool Approval Flow → Abort
916
+ * during pending approval (#409)".
833
917
  */
834
918
  async stream(agentId, threadId, message, options) {
835
919
  const agentState = this.getAgentStateOrThrow(agentId);
@@ -843,7 +927,7 @@ export class MastraHarness {
843
927
  abortSignal: options?.abortSignal,
844
928
  requireToolApproval: options?.requireToolApproval,
845
929
  });
846
- return this.toChatStreamResult(agentState, threadId, output);
930
+ return this.toChatStreamResult(agentState, threadId, output, options?.abortSignal);
847
931
  }
848
932
  // ── Tool Execution ───────────────────────────────────────────────
849
933
  /**
@@ -1080,6 +1164,47 @@ export class MastraHarness {
1080
1164
  }
1081
1165
  return state;
1082
1166
  }
1167
+ /**
1168
+ * Synchronously flips `serverState.currentStatus` (no-op when the next
1169
+ * status equals the current) and returns a closure that, when invoked,
1170
+ * emits the corresponding `mcp-server-status-changed` event. Returns
1171
+ * `undefined` when the status didn't change — the caller has nothing
1172
+ * to emit.
1173
+ *
1174
+ * Splitting the state flip from the emit lets callers update internal
1175
+ * state synchronously alongside `settled` (so a synchronous listener
1176
+ * reading `getMcpServerInfo()` from inside the discovery telemetry
1177
+ * callback observes the post-settle world per issue #373) while still
1178
+ * deferring the status-change emit until after the discovery telemetry
1179
+ * has fired.
1180
+ *
1181
+ * The returned closure swallows `DISPOSED` errors the same way the
1182
+ * discovery emit sites do: background discovery callbacks can resolve
1183
+ * after `shutdown()` has disposed the buses.
1184
+ */
1185
+ prepareMcpStatusChange(agentId, serverName, serverState, nextStatus, error) {
1186
+ const previousStatus = serverState.currentStatus;
1187
+ if (previousStatus === nextStatus)
1188
+ return undefined;
1189
+ serverState.currentStatus = nextStatus;
1190
+ return () => {
1191
+ try {
1192
+ this.buses.emitTelemetry({
1193
+ type: 'mcp-server-status-changed',
1194
+ timestamp: this.clock.now(),
1195
+ agentId,
1196
+ serverName,
1197
+ previousStatus,
1198
+ nextStatus,
1199
+ ...(error !== undefined ? { error } : {}),
1200
+ });
1201
+ }
1202
+ catch (emitErr) {
1203
+ if (!isDisposedError(emitErr))
1204
+ throw emitErr;
1205
+ }
1206
+ };
1207
+ }
1083
1208
  /**
1084
1209
  * Runs MCP discovery for one server: emits the
1085
1210
  * `mcp-server-discovery-started` / `…-completed` / `…-failed` telemetry
@@ -1095,8 +1220,10 @@ export class MastraHarness {
1095
1220
  * Errors observed during discovery are recorded on the entry's `error`
1096
1221
  * field; the returned promise resolves either way (it only rejects when
1097
1222
  * an aborted operation throws after we have already cleaned up). The
1098
- * `settled` flag is pinned by `.finally()` so `getMcpServerInfo()` can
1099
- * tell `Connecting` from `Connected`/`Error`.
1223
+ * `settled` flag is pinned BEFORE the terminal telemetry emit so a
1224
+ * synchronous listener calling `getMcpServerInfo()` from inside the
1225
+ * `mcp-server-discovery-{completed,failed}` callback observes the same
1226
+ * world the event described — see issue #373.
1100
1227
  */
1101
1228
  runMcpDiscovery(agentId, serverName, client, serverStates, epoch, abortSignal,
1102
1229
  // When set, reuse the caller's startTime and skip emitting `started` —
@@ -1135,7 +1262,13 @@ export class MastraHarness {
1135
1262
  const s = currentEntry();
1136
1263
  if (!s)
1137
1264
  return; // superseded — drop write + telemetry
1265
+ // Flip `settled` AND `currentStatus` BEFORE emitting any
1266
+ // terminal telemetry so a synchronous listener calling
1267
+ // getMcpServerInfo() from inside the `completed` callback
1268
+ // observes the same world the event described (issue #373).
1138
1269
  s.tools = namespacedTools;
1270
+ s.settled = true;
1271
+ const emitStatusChange = this.prepareMcpStatusChange(agentId, serverName, s, McpServerStatus.Connected);
1139
1272
  // listToolsetsWithErrors() runs in the background and can resolve
1140
1273
  // after `shutdown()` has disposed the buses. Swallow only `DISPOSED`
1141
1274
  // so a real listener exception still surfaces.
@@ -1153,6 +1286,7 @@ export class MastraHarness {
1153
1286
  if (!isDisposedError(emitErr))
1154
1287
  throw emitErr;
1155
1288
  }
1289
+ emitStatusChange?.();
1156
1290
  })
1157
1291
  .catch(async (err) => {
1158
1292
  if (abortSignal?.aborted) {
@@ -1164,10 +1298,18 @@ export class MastraHarness {
1164
1298
  const s = currentEntry();
1165
1299
  if (!s)
1166
1300
  return; // superseded — drop write + telemetry
1167
- s.error = err instanceof Error ? err.message : String(err);
1301
+ // Flip `settled` AND `currentStatus` BEFORE emitting any
1302
+ // terminal telemetry — see the matching comment in the
1303
+ // success path above (issue #373).
1304
+ const errorMessage = sanitizeMcpErrorMessage(err);
1305
+ const errorDetail = buildMcpErrorDetail(err);
1306
+ s.error = errorMessage;
1307
+ s.errorDetail = errorDetail;
1308
+ s.settled = true;
1309
+ const emitStatusChange = this.prepareMcpStatusChange(agentId, serverName, s, McpServerStatus.Error, errorMessage);
1168
1310
  this.buses.getLogBus()?.warn('MCP server discovery failed', {
1169
1311
  serverName,
1170
- error: err instanceof Error ? err.message : String(err),
1312
+ error: errorMessage,
1171
1313
  });
1172
1314
  try {
1173
1315
  this.buses.emitTelemetry({
@@ -1177,33 +1319,117 @@ export class MastraHarness {
1177
1319
  serverName,
1178
1320
  durationMs: this.clock.now().getTime() - startTime,
1179
1321
  error: err instanceof Error ? err : new Error(String(err)),
1322
+ errorDetail,
1180
1323
  });
1181
1324
  }
1182
1325
  catch (emitErr) {
1183
1326
  if (!isDisposedError(emitErr))
1184
1327
  throw emitErr;
1185
1328
  }
1329
+ emitStatusChange?.();
1186
1330
  }
1187
- })
1188
- .finally(() => {
1189
- const s = currentEntry();
1190
- if (s)
1191
- s.settled = true;
1192
1331
  });
1193
1332
  }
1194
- toChatStreamResult(agentState, threadId, output) {
1333
+ toChatStreamResult(agentState, threadId, output, abortSignal) {
1195
1334
  // Entries are keyed by threadId so each thread holds at most one entry.
1196
1335
  // Stale entries are overwritten here on the next stream; explicit cleanup
1197
1336
  // happens via destroyThread() / destroyAgent() / shutdown(). Proactive
1198
1337
  // removal on stream completion is not safe because the suspend/resume
1199
1338
  // flow (submitToolResult, approveToolCall) needs the entry after the stream closes.
1200
- agentState.activeRuns.set(threadId, { runId: output.runId, fullStream: output.fullStream });
1339
+ const newRun = { runId: output.runId, fullStream: output.fullStream };
1340
+ if (abortSignal) {
1341
+ newRun.disposeAbortListener = this.installPerRunAbortListener(agentState, threadId, newRun, abortSignal);
1342
+ }
1343
+ this.replaceActiveRun(agentState, threadId, newRun);
1201
1344
  const adapter = new MastraEventAdapter(this.buses.getLogBus());
1202
1345
  return {
1203
- eventStream: this.createEventStream(output.fullStream, adapter, agentState),
1346
+ eventStream: this.createEventStream(output.fullStream, adapter, agentState, abortSignal),
1204
1347
  textStream: this.readableStreamToAsyncGenerator(output.textStream),
1205
1348
  };
1206
1349
  }
1350
+ /**
1351
+ * Replace (or set) the active run for a thread, disposing any prior
1352
+ * entry's per-run abort listener so the prior signal cannot fire on the
1353
+ * new run.
1354
+ */
1355
+ replaceActiveRun(agentState, threadId, run) {
1356
+ const prior = agentState.activeRuns.get(threadId);
1357
+ prior?.disposeAbortListener?.();
1358
+ agentState.activeRuns.set(threadId, run);
1359
+ }
1360
+ /**
1361
+ * Remove the active run for a thread, disposing its per-run abort listener
1362
+ * if present. Safe to call when no entry exists.
1363
+ */
1364
+ removeActiveRun(agentState, threadId) {
1365
+ const prior = agentState.activeRuns.get(threadId);
1366
+ prior?.disposeAbortListener?.();
1367
+ agentState.activeRuns.delete(threadId);
1368
+ }
1369
+ /**
1370
+ * Wire `abortSignal.abort()` to tear down the suspended Mastra run for
1371
+ * `threadId`. Returns a disposer that removes the listener; callers must
1372
+ * invoke it whenever the entry is replaced or removed so a stale signal
1373
+ * cannot abort an unrelated continuation run.
1374
+ *
1375
+ * Identity guard: the listener checks `agentState.activeRuns.get(threadId) === run`
1376
+ * before calling `abortRunStream`, so a defensive race with another
1377
+ * `stream()` call on the same thread cannot double-abort.
1378
+ *
1379
+ * Closes #409 — without this listener, aborting while a tool-approval is
1380
+ * pending leaves Mastra's prepared run alive and `activeRuns[threadId]`
1381
+ * pointing at a runId the consumer can no longer settle, so subsequent
1382
+ * `approveToolCall` reaches Mastra and surfaces a confusing
1383
+ * "could not find a suspended run" error instead of the SDK-level
1384
+ * "no active run found" guard.
1385
+ */
1386
+ installPerRunAbortListener(agentState, threadId, run, abortSignal) {
1387
+ if (abortSignal.aborted) {
1388
+ // Listener attaches *after* agent.stream() has resolved — by which
1389
+ // point Mastra has already observed the same signal and started
1390
+ // its own abort flow. We still need to clear the activeRuns entry
1391
+ // so the SDK's "no active run" guard fires on subsequent
1392
+ // approve/decline calls.
1393
+ queueMicrotask(() => this.handlePerRunAbort(agentState, threadId, run));
1394
+ return () => {
1395
+ /* nothing to remove */
1396
+ };
1397
+ }
1398
+ const onAbort = () => this.handlePerRunAbort(agentState, threadId, run);
1399
+ abortSignal.addEventListener('abort', onAbort, { once: true });
1400
+ return () => abortSignal.removeEventListener('abort', onAbort);
1401
+ }
1402
+ handlePerRunAbort(agentState, threadId, run) {
1403
+ // The body runs synchronously inside `controller.abort()` (via
1404
+ // EventTarget.dispatchEvent), so a thrown error here would surface
1405
+ // as a synchronous throw at the consumer's `controller.abort()`
1406
+ // call site — wrapping the whole body in try/catch keeps that path
1407
+ // contained even if `LogBus.warn` itself throws via a misbehaving
1408
+ // subscriber.
1409
+ try {
1410
+ // Identity guard: only abort if this is still the active run
1411
+ // for the thread. A subsequent stream() / approveToolCall()
1412
+ // call may have replaced the entry; that path's own listener
1413
+ // is responsible for its run.
1414
+ if (agentState.activeRuns.get(threadId) !== run)
1415
+ return;
1416
+ agentState.activeRuns.delete(threadId);
1417
+ agentState.agent.abortRunStream(run.runId);
1418
+ }
1419
+ catch (err) {
1420
+ // Mastra's abortRunStream returns a boolean today; defend
1421
+ // against a future shape change so a regression doesn't crash
1422
+ // the consumer's abort dispatch.
1423
+ try {
1424
+ this.buses
1425
+ .getLogBus()
1426
+ ?.warn(`Failed to abort suspended run "${run.runId}" on thread "${threadId}": ${getErrorMessage(err)}`, { threadId, runId: run.runId }, err instanceof Error ? err : new Error(String(err)));
1427
+ }
1428
+ catch {
1429
+ /* swallow — synchronous abort dispatch must not throw */
1430
+ }
1431
+ }
1432
+ }
1207
1433
  async getMemoryOrThrow(agentId) {
1208
1434
  const agentState = this.getAgentStateOrThrow(agentId);
1209
1435
  const memory = await agentState.agent.getMemory();
@@ -1233,11 +1459,22 @@ export class MastraHarness {
1233
1459
  ctx.set(MASTRA_RESOURCE_ID_KEY, this.agentResourceId(agentId));
1234
1460
  return ctx;
1235
1461
  }
1236
- async *createEventStream(fullStream, adapter, agentState) {
1462
+ async *createEventStream(fullStream, adapter, agentState, abortSignal) {
1463
+ // Tracks whether the consumer has observed a terminal event already.
1464
+ // Either `finish` (natural completion) or any `error` event counts —
1465
+ // the SDK's `wrapEventStream` treats any error as terminal and
1466
+ // appends its own synthetic finish, so synthesizing an abort
1467
+ // terminal on top would duplicate the SDK invariant of one terminal
1468
+ // per turn. The narrower "only abort-coded errors count" predicate
1469
+ // would mis-fire on tripwire/regular errors that race the abort.
1470
+ let sawTerminal = false;
1237
1471
  for await (const chunk of this.readableStreamToAsyncGenerator(fullStream)) {
1238
1472
  const event = adapter.toChatEvent(chunk);
1239
1473
  if (!event)
1240
1474
  continue;
1475
+ if (event.type === 'finish' || event.type === 'error') {
1476
+ sawTerminal = true;
1477
+ }
1241
1478
  if (isEnrichableToolEvent(event)) {
1242
1479
  const toolName = event.type === 'tool-approval-request' ? event.toolCall.toolName : event.toolName;
1243
1480
  const metadata = lookupMcpToolMetadata(agentState, toolName);
@@ -1252,6 +1489,29 @@ export class MastraHarness {
1252
1489
  }
1253
1490
  yield event;
1254
1491
  }
1492
+ // The fullStream closed. If abort fired during a suspended-run path
1493
+ // (consumer aborted while a tool-approval was pending), Mastra
1494
+ // releases the prepared run silently — the fullStream just ends with
1495
+ // no `abort` chunk — so synthesize the terminal pair the SDK
1496
+ // invariant requires. See ARCHITECTURE.md → "Tool Approval Flow →
1497
+ // Abort during pending approval (#409)".
1498
+ //
1499
+ // Adapter-coupling note: this gate depends on `MastraEventAdapter`
1500
+ // not yielding a `finish` chunk between `tool-call-approval` and
1501
+ // the consumer's resume. True today (the adapter only emits
1502
+ // `finish` for the terminal Mastra `finish` chunk). A future
1503
+ // Mastra version that injects a step-level `finish` mid-suspend
1504
+ // would set `sawTerminal` early and silently suppress the synth.
1505
+ // If that lands, switch the predicate to track only the terminal
1506
+ // `finish` (e.g. via a per-stream "suspended" flag).
1507
+ if (!sawTerminal && abortSignal?.aborted) {
1508
+ yield {
1509
+ type: 'error',
1510
+ error: new DOMException('The stream was aborted.', 'AbortError'),
1511
+ code: 'abort',
1512
+ };
1513
+ yield { type: 'finish', finishReason: 'error' };
1514
+ }
1255
1515
  }
1256
1516
  async *readableStreamToAsyncGenerator(stream) {
1257
1517
  const reader = stream.getReader();
@@ -0,0 +1,34 @@
1
+ import type { McpServerErrorCategory, McpServerErrorDetail } from '@salesforce/sfdx-agent-sdk';
2
+ /**
3
+ * Strip V8 stack frames (" at <frame>") and any framing that arrived inside
4
+ * the message string. The result is a single, human-readable summary line safe
5
+ * for UI banners, telemetry tags, and Sentry fingerprints. The full stack is
6
+ * still available on the underlying `Error` instance (`error.stack`) for
7
+ * debug-mode log surfaces.
8
+ *
9
+ * Inputs are loose on purpose: a string passes through, a non-Error object's
10
+ * `String(...)` form is sanitized the same way, and `undefined`/`null` round-trip
11
+ * through `String(...)`.
12
+ */
13
+ export declare function sanitizeMcpErrorMessage(err: unknown): string;
14
+ /**
15
+ * Map a caught error to the SDK's stable {@link McpServerErrorCategory}. The
16
+ * lookup is layered:
17
+ *
18
+ * 1. `DOMException` `AbortError` and Node's `code === 'ABORT_ERR'` map to
19
+ * `'aborted'` regardless of the `.message`.
20
+ * 2. MCP-shaped errors (an `Error` carrying a numeric `code`, see
21
+ * {@link isMcpErrorLike}) match by JSON-RPC 2.0 code (`-32001` etc.).
22
+ * 3. Free-form `.message` text is matched against substrings the underlying
23
+ * transports surface (`401`, `403`, `5xx`, `ECONNREFUSED`, etc.). This is
24
+ * intentionally a small lookup — categories are stable, message shapes
25
+ * are not, so the heuristic stays narrow and falls back to `'unknown'`.
26
+ */
27
+ export declare function classifyMcpError(err: unknown): McpServerErrorCategory;
28
+ /**
29
+ * Build the SDK-side {@link McpServerErrorDetail} from a caught error. The
30
+ * underlying JSON-RPC `code` (when the error is MCP-shaped, see
31
+ * {@link isMcpErrorLike}) is preserved so consumers can route on the code
32
+ * without having to introspect `cause`.
33
+ */
34
+ export declare function buildMcpErrorDetail(err: unknown): McpServerErrorDetail;
@@ -0,0 +1,131 @@
1
+ /*
2
+ * Copyright 2026, Salesforce, Inc. All rights reserved.
3
+ * See LICENSE.txt for license terms.
4
+ */
5
+ import { isAbortError } from '@salesforce/agentic-common';
6
+ /**
7
+ * Structural duck-type for MCP-shaped errors. Matches the upstream
8
+ * `@modelcontextprotocol/sdk` `McpError` class (`extends Error`, numeric
9
+ * `code`) without an `instanceof` check. We avoid the import for two reasons:
10
+ *
11
+ * 1. **Dual-package hazard.** If `@mastra/mcp` ever pins
12
+ * `@modelcontextprotocol/sdk` to a version that diverges from one this
13
+ * package would pin, npm hoists two copies and `instanceof` returns false
14
+ * across realms. Structural matching survives that case.
15
+ * 2. **JSON-RPC codes are the contract, not the class.** The numeric codes
16
+ * (`-32001 RequestTimeout`, `-32603 InternalError`, …) are stable per the
17
+ * JSON-RPC 2.0 spec. Any error with `code: number` originating from
18
+ * MCP-shaped infrastructure will match — which is what consumers want.
19
+ *
20
+ * Node's `SystemError` (e.g. `ECONNREFUSED`) also has a `.code` field but as
21
+ * a `string`, so the `typeof === 'number'` check disambiguates.
22
+ */
23
+ function isMcpErrorLike(err) {
24
+ return err instanceof Error && typeof err.code === 'number';
25
+ }
26
+ /**
27
+ * Strip V8 stack frames (" at <frame>") and any framing that arrived inside
28
+ * the message string. The result is a single, human-readable summary line safe
29
+ * for UI banners, telemetry tags, and Sentry fingerprints. The full stack is
30
+ * still available on the underlying `Error` instance (`error.stack`) for
31
+ * debug-mode log surfaces.
32
+ *
33
+ * Inputs are loose on purpose: a string passes through, a non-Error object's
34
+ * `String(...)` form is sanitized the same way, and `undefined`/`null` round-trip
35
+ * through `String(...)`.
36
+ */
37
+ export function sanitizeMcpErrorMessage(err) {
38
+ const raw = err instanceof Error ? err.message : String(err);
39
+ const lines = raw.split(/\r?\n/);
40
+ // Keep the first line that is NOT a V8 stack frame. V8 frames start with
41
+ // arbitrary leading whitespace followed by "at " (matched at line start).
42
+ for (const line of lines) {
43
+ if (!/^\s*at\s/.test(line)) {
44
+ return line.trim();
45
+ }
46
+ }
47
+ // All lines were stack frames — fall back to the raw single-line input.
48
+ return lines[0]?.trim() ?? '';
49
+ }
50
+ /**
51
+ * Map a caught error to the SDK's stable {@link McpServerErrorCategory}. The
52
+ * lookup is layered:
53
+ *
54
+ * 1. `DOMException` `AbortError` and Node's `code === 'ABORT_ERR'` map to
55
+ * `'aborted'` regardless of the `.message`.
56
+ * 2. MCP-shaped errors (an `Error` carrying a numeric `code`, see
57
+ * {@link isMcpErrorLike}) match by JSON-RPC 2.0 code (`-32001` etc.).
58
+ * 3. Free-form `.message` text is matched against substrings the underlying
59
+ * transports surface (`401`, `403`, `5xx`, `ECONNREFUSED`, etc.). This is
60
+ * intentionally a small lookup — categories are stable, message shapes
61
+ * are not, so the heuristic stays narrow and falls back to `'unknown'`.
62
+ */
63
+ export function classifyMcpError(err) {
64
+ if (isAbortError(err))
65
+ return 'aborted';
66
+ if (isMcpErrorLike(err)) {
67
+ switch (err.code) {
68
+ case -32001: // RequestTimeout
69
+ return 'connect-timeout';
70
+ case -32700: // ParseError
71
+ case -32600: // InvalidRequest
72
+ case -32601: // MethodNotFound
73
+ case -32602: // InvalidParams
74
+ case -32603: // InternalError
75
+ return 'protocol-error';
76
+ case -32000: // ConnectionClosed
77
+ return 'transport-eof';
78
+ default:
79
+ return 'unknown';
80
+ }
81
+ }
82
+ const message = err instanceof Error ? err.message : typeof err === 'string' ? err : '';
83
+ if (/\b401\b/.test(message))
84
+ return 'http-401';
85
+ if (/\b403\b/.test(message))
86
+ return 'http-403';
87
+ if (/\b4\d\d\b/.test(message))
88
+ return 'http-4xx';
89
+ if (/\b5\d\d\b/.test(message))
90
+ return 'http-5xx';
91
+ if (/ECONNREFUSED|ECONNRESET|EPIPE|socket hang up/i.test(message))
92
+ return 'transport-eof';
93
+ if (/timed out|timeout/i.test(message))
94
+ return 'connect-timeout';
95
+ return 'unknown';
96
+ }
97
+ /**
98
+ * Whether the SDK considers the failure transient (worth a retry via
99
+ * `Agent.reconnectMcpServer` / `Agent.refreshMcpAuth`). Aborted operations and
100
+ * permanent protocol violations are non-retriable; everything else is retriable
101
+ * by default — failing-open here is correct because the consumer always has
102
+ * the option to ignore the hint.
103
+ */
104
+ function isCategoryRetriable(category) {
105
+ switch (category) {
106
+ case 'aborted':
107
+ case 'protocol-error':
108
+ case 'config-error':
109
+ return false;
110
+ default:
111
+ return true;
112
+ }
113
+ }
114
+ /**
115
+ * Build the SDK-side {@link McpServerErrorDetail} from a caught error. The
116
+ * underlying JSON-RPC `code` (when the error is MCP-shaped, see
117
+ * {@link isMcpErrorLike}) is preserved so consumers can route on the code
118
+ * without having to introspect `cause`.
119
+ */
120
+ export function buildMcpErrorDetail(err) {
121
+ const category = classifyMcpError(err);
122
+ const detail = {
123
+ category,
124
+ retriable: isCategoryRetriable(category),
125
+ };
126
+ if (isMcpErrorLike(err)) {
127
+ detail.code = err.code;
128
+ }
129
+ return detail;
130
+ }
131
+ //# sourceMappingURL=mcp-error-classifier.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/sfdx-agent-harness-mastra",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Mastra-backed AgentHarness implementation for @salesforce/sfdx-agent-sdk",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -28,20 +28,21 @@
28
28
  ],
29
29
  "dependencies": {
30
30
  "@ai-sdk/provider": "^3.0.10",
31
+ "@libsql/client": "^0.15.15",
31
32
  "@mastra/core": "^1.36.0",
32
33
  "@mastra/libsql": "^1.11.1",
33
34
  "@mastra/mcp": "^1.8.0",
34
35
  "@mastra/memory": "^1.19.0",
35
36
  "@mastra/schema-compat": "^1.2.10",
36
- "@salesforce/agentic-common": "0.5.0",
37
- "@salesforce/llm-gateway-sdk": "0.6.0"
37
+ "@salesforce/agentic-common": "0.6.0",
38
+ "@salesforce/llm-gateway-sdk": "0.7.0"
38
39
  },
39
40
  "peerDependencies": {
40
- "@salesforce/sfdx-agent-sdk": "0.9.0"
41
+ "@salesforce/sfdx-agent-sdk": "0.10.0"
41
42
  },
42
43
  "devDependencies": {
43
44
  "@eslint/js": "^10.0.1",
44
- "@salesforce/sfdx-agent-sdk": "0.9.0",
45
+ "@salesforce/sfdx-agent-sdk": "0.10.0",
45
46
  "@types/node": "^22.19.17",
46
47
  "@vitest/coverage-istanbul": "^4.1.7",
47
48
  "@vitest/eslint-plugin": "^1.6.17",