@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 +18 -0
- package/dist/mastra-harness.d.ts +97 -3
- package/dist/mastra-harness.js +284 -24
- package/dist/mcp-error-classifier.d.ts +34 -0
- package/dist/mcp-error-classifier.js +131 -0
- package/package.json +6 -5
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
|
package/dist/mastra-harness.d.ts
CHANGED
|
@@ -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
|
|
362
|
-
*
|
|
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
|
/**
|
package/dist/mastra-harness.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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 =
|
|
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)
|
|
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
|
|
1099
|
-
*
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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.
|
|
37
|
-
"@salesforce/llm-gateway-sdk": "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.
|
|
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.
|
|
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",
|