@salesforce/sfdx-agent-harness-mastra 0.8.0 → 0.10.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 +303 -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,29 @@ 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
|
+
// Fold the WAL into the main DB and switch journal_mode=DELETE
|
|
421
|
+
// so SQLite unlinks the -wal/-shm sidecars on close. Without this,
|
|
422
|
+
// Windows holds the sidecars open well past `client.close()` and
|
|
423
|
+
// racing `fs.rm` calls (e.g. vitest's per-test cleanup) crash the
|
|
424
|
+
// worker. Mirrors the fix Mastra shipped on `LibSQLStore.close()`
|
|
425
|
+
// in `@mastra/libsql@1.12.0-alpha.0`. Replace this block with a
|
|
426
|
+
// call to `storage.close()` once that release ships stable — see
|
|
427
|
+
// https://github.com/forcedotcom/agentic-dx/issues/475.
|
|
428
|
+
try {
|
|
429
|
+
await this.libsqlClient.execute('PRAGMA wal_checkpoint(TRUNCATE);');
|
|
430
|
+
await this.libsqlClient.execute('PRAGMA journal_mode=DELETE;');
|
|
431
|
+
}
|
|
432
|
+
catch (err) {
|
|
433
|
+
// Best-effort: still close even if checkpoint fails (e.g. the
|
|
434
|
+
// client was already closed by a prior shutdown path).
|
|
435
|
+
this.buses.getLogBus()?.warn('libsql WAL checkpoint failed before close', {
|
|
436
|
+
error: getErrorMessage(err),
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
this.libsqlClient.close();
|
|
377
440
|
this.buses.dispose();
|
|
378
441
|
}
|
|
379
442
|
onTelemetry(callback) {
|
|
@@ -424,6 +487,7 @@ export class MastraHarness {
|
|
|
424
487
|
// after the synchronous `set` and `ready` assignment complete.
|
|
425
488
|
const entry = {
|
|
426
489
|
client,
|
|
490
|
+
currentStatus: McpServerStatus.Connecting,
|
|
427
491
|
epoch: 0,
|
|
428
492
|
ready: Promise.resolve(),
|
|
429
493
|
settled: false,
|
|
@@ -556,6 +620,9 @@ export class MastraHarness {
|
|
|
556
620
|
*/
|
|
557
621
|
async destroyAgent(agentId) {
|
|
558
622
|
const state = this.getAgentStateOrThrow(agentId);
|
|
623
|
+
for (const run of state.activeRuns.values())
|
|
624
|
+
run.disposeAbortListener?.();
|
|
625
|
+
state.activeRuns.clear();
|
|
559
626
|
await Promise.allSettled(Array.from(state.mcpServers.values()).map(async (s) => {
|
|
560
627
|
await s.ready.catch(() => undefined);
|
|
561
628
|
await s.client.disconnect();
|
|
@@ -576,19 +643,35 @@ export class MastraHarness {
|
|
|
576
643
|
const state = this.getAgentStateOrThrow(agentId);
|
|
577
644
|
const results = [];
|
|
578
645
|
for (const [name, serverState] of state.mcpServers) {
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
646
|
+
// `currentStatus` is the source of truth: it tracks `Reconnecting`
|
|
647
|
+
// (transient state during a reconnect on a previously-Connected
|
|
648
|
+
// server) which the `settled`/`error` pair cannot represent.
|
|
649
|
+
// `settled` still gates whether tools are visible — Mastra's
|
|
650
|
+
// discovery has resolved at most once per epoch.
|
|
651
|
+
const status = serverState.currentStatus;
|
|
652
|
+
if (status === McpServerStatus.Connected && serverState.settled) {
|
|
586
653
|
results.push({
|
|
587
654
|
name,
|
|
588
655
|
status: McpServerStatus.Connected,
|
|
589
656
|
tools: mapMastraToolsToMcpToolInfo(serverState.tools, this.buses.getLogBus()),
|
|
590
657
|
});
|
|
591
658
|
}
|
|
659
|
+
else if (status === McpServerStatus.Error && serverState.settled) {
|
|
660
|
+
const info = {
|
|
661
|
+
error: serverState.error,
|
|
662
|
+
name,
|
|
663
|
+
status: McpServerStatus.Error,
|
|
664
|
+
tools: [],
|
|
665
|
+
};
|
|
666
|
+
if (serverState.errorDetail)
|
|
667
|
+
info.errorDetail = serverState.errorDetail;
|
|
668
|
+
results.push(info);
|
|
669
|
+
}
|
|
670
|
+
else {
|
|
671
|
+
// Connecting (initial), Reconnecting (mid-cycle), or any
|
|
672
|
+
// status whose discovery has not yet settled.
|
|
673
|
+
results.push({ name, status, tools: [] });
|
|
674
|
+
}
|
|
592
675
|
}
|
|
593
676
|
if (state.mcpConfig) {
|
|
594
677
|
for (const [name, serverConfig] of Object.entries(state.mcpConfig)) {
|
|
@@ -627,6 +710,17 @@ export class MastraHarness {
|
|
|
627
710
|
serverState.error = undefined;
|
|
628
711
|
serverState.tools = {};
|
|
629
712
|
serverState.settled = false;
|
|
713
|
+
// Status transition before transport work begins. A reconnect on a
|
|
714
|
+
// previously-`Connected` server lands in `Reconnecting` (transport is
|
|
715
|
+
// being cycled); reconnects from `Error` are a recovery path and use
|
|
716
|
+
// the regular `Connecting` placeholder so the (prev,next) pair on
|
|
717
|
+
// success reads `Connecting → Connected` like an initial discovery.
|
|
718
|
+
// Concurrent reconnects (already `Reconnecting`) coalesce via the
|
|
719
|
+
// emit-helper's same-status no-op.
|
|
720
|
+
const transientStatus = serverState.currentStatus === McpServerStatus.Connected
|
|
721
|
+
? McpServerStatus.Reconnecting
|
|
722
|
+
: McpServerStatus.Connecting;
|
|
723
|
+
this.prepareMcpStatusChange(agentId, serverName, serverState, transientStatus)?.();
|
|
630
724
|
// Emit `started` BEFORE awaiting the transport reconnect so subscribers
|
|
631
725
|
// see started → (completed | failed) timestamps in chronological order.
|
|
632
726
|
// The previous shape had `started` emitted from the catch block AFTER
|
|
@@ -650,9 +744,12 @@ export class MastraHarness {
|
|
|
650
744
|
// Drop telemetry + state writes if a newer epoch has superseded us.
|
|
651
745
|
if (state.mcpServers.get(serverName)?.epoch !== epoch)
|
|
652
746
|
return;
|
|
653
|
-
const message =
|
|
747
|
+
const message = sanitizeMcpErrorMessage(err);
|
|
748
|
+
const errorDetail = buildMcpErrorDetail(err);
|
|
654
749
|
serverState.error = message;
|
|
750
|
+
serverState.errorDetail = errorDetail;
|
|
655
751
|
serverState.settled = true;
|
|
752
|
+
const emitStatusChange = this.prepareMcpStatusChange(agentId, serverName, serverState, McpServerStatus.Error, message);
|
|
656
753
|
this.buses.getLogBus()?.warn('MCP server reconnect failed', {
|
|
657
754
|
serverName,
|
|
658
755
|
error: message,
|
|
@@ -665,12 +762,14 @@ export class MastraHarness {
|
|
|
665
762
|
serverName,
|
|
666
763
|
durationMs: this.clock.now().getTime() - startTime,
|
|
667
764
|
error: err instanceof Error ? err : new Error(message),
|
|
765
|
+
errorDetail,
|
|
668
766
|
});
|
|
669
767
|
}
|
|
670
768
|
catch (emitErr) {
|
|
671
769
|
if (!isDisposedError(emitErr))
|
|
672
770
|
throw emitErr;
|
|
673
771
|
}
|
|
772
|
+
emitStatusChange?.();
|
|
674
773
|
return;
|
|
675
774
|
}
|
|
676
775
|
// Pass the existing startTime so runMcpDiscovery does NOT emit its own
|
|
@@ -703,7 +802,7 @@ export class MastraHarness {
|
|
|
703
802
|
* - MUST remove any active run tracked for this thread to prevent stale references.
|
|
704
803
|
*/
|
|
705
804
|
async destroyThread(agentId, threadId) {
|
|
706
|
-
this.getAgentStateOrThrow(agentId)
|
|
805
|
+
this.removeActiveRun(this.getAgentStateOrThrow(agentId), threadId);
|
|
707
806
|
const memory = await this.getMemoryOrThrow(agentId);
|
|
708
807
|
await memory.deleteThread(threadId);
|
|
709
808
|
}
|
|
@@ -830,6 +929,10 @@ export class MastraHarness {
|
|
|
830
929
|
* - MUST return a `ChatStreamResult` with `textStream` and `eventStream`.
|
|
831
930
|
* - On pre-stream rejection from `agent.stream()`, MUST re-throw without emitting telemetry —
|
|
832
931
|
* the SDK's chat-session boundary catches the rejection and emits `chat-stream-error`.
|
|
932
|
+
* - MUST install a per-run abort listener on `options.abortSignal` so that aborting
|
|
933
|
+
* while a tool-approval is pending tears down the suspended Mastra run and clears
|
|
934
|
+
* the thread's `ActiveRun` entry. See ARCHITECTURE.md → "Tool Approval Flow → Abort
|
|
935
|
+
* during pending approval (#409)".
|
|
833
936
|
*/
|
|
834
937
|
async stream(agentId, threadId, message, options) {
|
|
835
938
|
const agentState = this.getAgentStateOrThrow(agentId);
|
|
@@ -843,7 +946,7 @@ export class MastraHarness {
|
|
|
843
946
|
abortSignal: options?.abortSignal,
|
|
844
947
|
requireToolApproval: options?.requireToolApproval,
|
|
845
948
|
});
|
|
846
|
-
return this.toChatStreamResult(agentState, threadId, output);
|
|
949
|
+
return this.toChatStreamResult(agentState, threadId, output, options?.abortSignal);
|
|
847
950
|
}
|
|
848
951
|
// ── Tool Execution ───────────────────────────────────────────────
|
|
849
952
|
/**
|
|
@@ -1080,6 +1183,47 @@ export class MastraHarness {
|
|
|
1080
1183
|
}
|
|
1081
1184
|
return state;
|
|
1082
1185
|
}
|
|
1186
|
+
/**
|
|
1187
|
+
* Synchronously flips `serverState.currentStatus` (no-op when the next
|
|
1188
|
+
* status equals the current) and returns a closure that, when invoked,
|
|
1189
|
+
* emits the corresponding `mcp-server-status-changed` event. Returns
|
|
1190
|
+
* `undefined` when the status didn't change — the caller has nothing
|
|
1191
|
+
* to emit.
|
|
1192
|
+
*
|
|
1193
|
+
* Splitting the state flip from the emit lets callers update internal
|
|
1194
|
+
* state synchronously alongside `settled` (so a synchronous listener
|
|
1195
|
+
* reading `getMcpServerInfo()` from inside the discovery telemetry
|
|
1196
|
+
* callback observes the post-settle world per issue #373) while still
|
|
1197
|
+
* deferring the status-change emit until after the discovery telemetry
|
|
1198
|
+
* has fired.
|
|
1199
|
+
*
|
|
1200
|
+
* The returned closure swallows `DISPOSED` errors the same way the
|
|
1201
|
+
* discovery emit sites do: background discovery callbacks can resolve
|
|
1202
|
+
* after `shutdown()` has disposed the buses.
|
|
1203
|
+
*/
|
|
1204
|
+
prepareMcpStatusChange(agentId, serverName, serverState, nextStatus, error) {
|
|
1205
|
+
const previousStatus = serverState.currentStatus;
|
|
1206
|
+
if (previousStatus === nextStatus)
|
|
1207
|
+
return undefined;
|
|
1208
|
+
serverState.currentStatus = nextStatus;
|
|
1209
|
+
return () => {
|
|
1210
|
+
try {
|
|
1211
|
+
this.buses.emitTelemetry({
|
|
1212
|
+
type: 'mcp-server-status-changed',
|
|
1213
|
+
timestamp: this.clock.now(),
|
|
1214
|
+
agentId,
|
|
1215
|
+
serverName,
|
|
1216
|
+
previousStatus,
|
|
1217
|
+
nextStatus,
|
|
1218
|
+
...(error !== undefined ? { error } : {}),
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
catch (emitErr) {
|
|
1222
|
+
if (!isDisposedError(emitErr))
|
|
1223
|
+
throw emitErr;
|
|
1224
|
+
}
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1083
1227
|
/**
|
|
1084
1228
|
* Runs MCP discovery for one server: emits the
|
|
1085
1229
|
* `mcp-server-discovery-started` / `…-completed` / `…-failed` telemetry
|
|
@@ -1095,8 +1239,10 @@ export class MastraHarness {
|
|
|
1095
1239
|
* Errors observed during discovery are recorded on the entry's `error`
|
|
1096
1240
|
* field; the returned promise resolves either way (it only rejects when
|
|
1097
1241
|
* an aborted operation throws after we have already cleaned up). The
|
|
1098
|
-
* `settled` flag is pinned
|
|
1099
|
-
*
|
|
1242
|
+
* `settled` flag is pinned BEFORE the terminal telemetry emit so a
|
|
1243
|
+
* synchronous listener calling `getMcpServerInfo()` from inside the
|
|
1244
|
+
* `mcp-server-discovery-{completed,failed}` callback observes the same
|
|
1245
|
+
* world the event described — see issue #373.
|
|
1100
1246
|
*/
|
|
1101
1247
|
runMcpDiscovery(agentId, serverName, client, serverStates, epoch, abortSignal,
|
|
1102
1248
|
// When set, reuse the caller's startTime and skip emitting `started` —
|
|
@@ -1135,7 +1281,13 @@ export class MastraHarness {
|
|
|
1135
1281
|
const s = currentEntry();
|
|
1136
1282
|
if (!s)
|
|
1137
1283
|
return; // superseded — drop write + telemetry
|
|
1284
|
+
// Flip `settled` AND `currentStatus` BEFORE emitting any
|
|
1285
|
+
// terminal telemetry so a synchronous listener calling
|
|
1286
|
+
// getMcpServerInfo() from inside the `completed` callback
|
|
1287
|
+
// observes the same world the event described (issue #373).
|
|
1138
1288
|
s.tools = namespacedTools;
|
|
1289
|
+
s.settled = true;
|
|
1290
|
+
const emitStatusChange = this.prepareMcpStatusChange(agentId, serverName, s, McpServerStatus.Connected);
|
|
1139
1291
|
// listToolsetsWithErrors() runs in the background and can resolve
|
|
1140
1292
|
// after `shutdown()` has disposed the buses. Swallow only `DISPOSED`
|
|
1141
1293
|
// so a real listener exception still surfaces.
|
|
@@ -1153,6 +1305,7 @@ export class MastraHarness {
|
|
|
1153
1305
|
if (!isDisposedError(emitErr))
|
|
1154
1306
|
throw emitErr;
|
|
1155
1307
|
}
|
|
1308
|
+
emitStatusChange?.();
|
|
1156
1309
|
})
|
|
1157
1310
|
.catch(async (err) => {
|
|
1158
1311
|
if (abortSignal?.aborted) {
|
|
@@ -1164,10 +1317,18 @@ export class MastraHarness {
|
|
|
1164
1317
|
const s = currentEntry();
|
|
1165
1318
|
if (!s)
|
|
1166
1319
|
return; // superseded — drop write + telemetry
|
|
1167
|
-
|
|
1320
|
+
// Flip `settled` AND `currentStatus` BEFORE emitting any
|
|
1321
|
+
// terminal telemetry — see the matching comment in the
|
|
1322
|
+
// success path above (issue #373).
|
|
1323
|
+
const errorMessage = sanitizeMcpErrorMessage(err);
|
|
1324
|
+
const errorDetail = buildMcpErrorDetail(err);
|
|
1325
|
+
s.error = errorMessage;
|
|
1326
|
+
s.errorDetail = errorDetail;
|
|
1327
|
+
s.settled = true;
|
|
1328
|
+
const emitStatusChange = this.prepareMcpStatusChange(agentId, serverName, s, McpServerStatus.Error, errorMessage);
|
|
1168
1329
|
this.buses.getLogBus()?.warn('MCP server discovery failed', {
|
|
1169
1330
|
serverName,
|
|
1170
|
-
error:
|
|
1331
|
+
error: errorMessage,
|
|
1171
1332
|
});
|
|
1172
1333
|
try {
|
|
1173
1334
|
this.buses.emitTelemetry({
|
|
@@ -1177,33 +1338,117 @@ export class MastraHarness {
|
|
|
1177
1338
|
serverName,
|
|
1178
1339
|
durationMs: this.clock.now().getTime() - startTime,
|
|
1179
1340
|
error: err instanceof Error ? err : new Error(String(err)),
|
|
1341
|
+
errorDetail,
|
|
1180
1342
|
});
|
|
1181
1343
|
}
|
|
1182
1344
|
catch (emitErr) {
|
|
1183
1345
|
if (!isDisposedError(emitErr))
|
|
1184
1346
|
throw emitErr;
|
|
1185
1347
|
}
|
|
1348
|
+
emitStatusChange?.();
|
|
1186
1349
|
}
|
|
1187
|
-
})
|
|
1188
|
-
.finally(() => {
|
|
1189
|
-
const s = currentEntry();
|
|
1190
|
-
if (s)
|
|
1191
|
-
s.settled = true;
|
|
1192
1350
|
});
|
|
1193
1351
|
}
|
|
1194
|
-
toChatStreamResult(agentState, threadId, output) {
|
|
1352
|
+
toChatStreamResult(agentState, threadId, output, abortSignal) {
|
|
1195
1353
|
// Entries are keyed by threadId so each thread holds at most one entry.
|
|
1196
1354
|
// Stale entries are overwritten here on the next stream; explicit cleanup
|
|
1197
1355
|
// happens via destroyThread() / destroyAgent() / shutdown(). Proactive
|
|
1198
1356
|
// removal on stream completion is not safe because the suspend/resume
|
|
1199
1357
|
// flow (submitToolResult, approveToolCall) needs the entry after the stream closes.
|
|
1200
|
-
|
|
1358
|
+
const newRun = { runId: output.runId, fullStream: output.fullStream };
|
|
1359
|
+
if (abortSignal) {
|
|
1360
|
+
newRun.disposeAbortListener = this.installPerRunAbortListener(agentState, threadId, newRun, abortSignal);
|
|
1361
|
+
}
|
|
1362
|
+
this.replaceActiveRun(agentState, threadId, newRun);
|
|
1201
1363
|
const adapter = new MastraEventAdapter(this.buses.getLogBus());
|
|
1202
1364
|
return {
|
|
1203
|
-
eventStream: this.createEventStream(output.fullStream, adapter, agentState),
|
|
1365
|
+
eventStream: this.createEventStream(output.fullStream, adapter, agentState, abortSignal),
|
|
1204
1366
|
textStream: this.readableStreamToAsyncGenerator(output.textStream),
|
|
1205
1367
|
};
|
|
1206
1368
|
}
|
|
1369
|
+
/**
|
|
1370
|
+
* Replace (or set) the active run for a thread, disposing any prior
|
|
1371
|
+
* entry's per-run abort listener so the prior signal cannot fire on the
|
|
1372
|
+
* new run.
|
|
1373
|
+
*/
|
|
1374
|
+
replaceActiveRun(agentState, threadId, run) {
|
|
1375
|
+
const prior = agentState.activeRuns.get(threadId);
|
|
1376
|
+
prior?.disposeAbortListener?.();
|
|
1377
|
+
agentState.activeRuns.set(threadId, run);
|
|
1378
|
+
}
|
|
1379
|
+
/**
|
|
1380
|
+
* Remove the active run for a thread, disposing its per-run abort listener
|
|
1381
|
+
* if present. Safe to call when no entry exists.
|
|
1382
|
+
*/
|
|
1383
|
+
removeActiveRun(agentState, threadId) {
|
|
1384
|
+
const prior = agentState.activeRuns.get(threadId);
|
|
1385
|
+
prior?.disposeAbortListener?.();
|
|
1386
|
+
agentState.activeRuns.delete(threadId);
|
|
1387
|
+
}
|
|
1388
|
+
/**
|
|
1389
|
+
* Wire `abortSignal.abort()` to tear down the suspended Mastra run for
|
|
1390
|
+
* `threadId`. Returns a disposer that removes the listener; callers must
|
|
1391
|
+
* invoke it whenever the entry is replaced or removed so a stale signal
|
|
1392
|
+
* cannot abort an unrelated continuation run.
|
|
1393
|
+
*
|
|
1394
|
+
* Identity guard: the listener checks `agentState.activeRuns.get(threadId) === run`
|
|
1395
|
+
* before calling `abortRunStream`, so a defensive race with another
|
|
1396
|
+
* `stream()` call on the same thread cannot double-abort.
|
|
1397
|
+
*
|
|
1398
|
+
* Closes #409 — without this listener, aborting while a tool-approval is
|
|
1399
|
+
* pending leaves Mastra's prepared run alive and `activeRuns[threadId]`
|
|
1400
|
+
* pointing at a runId the consumer can no longer settle, so subsequent
|
|
1401
|
+
* `approveToolCall` reaches Mastra and surfaces a confusing
|
|
1402
|
+
* "could not find a suspended run" error instead of the SDK-level
|
|
1403
|
+
* "no active run found" guard.
|
|
1404
|
+
*/
|
|
1405
|
+
installPerRunAbortListener(agentState, threadId, run, abortSignal) {
|
|
1406
|
+
if (abortSignal.aborted) {
|
|
1407
|
+
// Listener attaches *after* agent.stream() has resolved — by which
|
|
1408
|
+
// point Mastra has already observed the same signal and started
|
|
1409
|
+
// its own abort flow. We still need to clear the activeRuns entry
|
|
1410
|
+
// so the SDK's "no active run" guard fires on subsequent
|
|
1411
|
+
// approve/decline calls.
|
|
1412
|
+
queueMicrotask(() => this.handlePerRunAbort(agentState, threadId, run));
|
|
1413
|
+
return () => {
|
|
1414
|
+
/* nothing to remove */
|
|
1415
|
+
};
|
|
1416
|
+
}
|
|
1417
|
+
const onAbort = () => this.handlePerRunAbort(agentState, threadId, run);
|
|
1418
|
+
abortSignal.addEventListener('abort', onAbort, { once: true });
|
|
1419
|
+
return () => abortSignal.removeEventListener('abort', onAbort);
|
|
1420
|
+
}
|
|
1421
|
+
handlePerRunAbort(agentState, threadId, run) {
|
|
1422
|
+
// The body runs synchronously inside `controller.abort()` (via
|
|
1423
|
+
// EventTarget.dispatchEvent), so a thrown error here would surface
|
|
1424
|
+
// as a synchronous throw at the consumer's `controller.abort()`
|
|
1425
|
+
// call site — wrapping the whole body in try/catch keeps that path
|
|
1426
|
+
// contained even if `LogBus.warn` itself throws via a misbehaving
|
|
1427
|
+
// subscriber.
|
|
1428
|
+
try {
|
|
1429
|
+
// Identity guard: only abort if this is still the active run
|
|
1430
|
+
// for the thread. A subsequent stream() / approveToolCall()
|
|
1431
|
+
// call may have replaced the entry; that path's own listener
|
|
1432
|
+
// is responsible for its run.
|
|
1433
|
+
if (agentState.activeRuns.get(threadId) !== run)
|
|
1434
|
+
return;
|
|
1435
|
+
agentState.activeRuns.delete(threadId);
|
|
1436
|
+
agentState.agent.abortRunStream(run.runId);
|
|
1437
|
+
}
|
|
1438
|
+
catch (err) {
|
|
1439
|
+
// Mastra's abortRunStream returns a boolean today; defend
|
|
1440
|
+
// against a future shape change so a regression doesn't crash
|
|
1441
|
+
// the consumer's abort dispatch.
|
|
1442
|
+
try {
|
|
1443
|
+
this.buses
|
|
1444
|
+
.getLogBus()
|
|
1445
|
+
?.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)));
|
|
1446
|
+
}
|
|
1447
|
+
catch {
|
|
1448
|
+
/* swallow — synchronous abort dispatch must not throw */
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1207
1452
|
async getMemoryOrThrow(agentId) {
|
|
1208
1453
|
const agentState = this.getAgentStateOrThrow(agentId);
|
|
1209
1454
|
const memory = await agentState.agent.getMemory();
|
|
@@ -1233,11 +1478,22 @@ export class MastraHarness {
|
|
|
1233
1478
|
ctx.set(MASTRA_RESOURCE_ID_KEY, this.agentResourceId(agentId));
|
|
1234
1479
|
return ctx;
|
|
1235
1480
|
}
|
|
1236
|
-
async *createEventStream(fullStream, adapter, agentState) {
|
|
1481
|
+
async *createEventStream(fullStream, adapter, agentState, abortSignal) {
|
|
1482
|
+
// Tracks whether the consumer has observed a terminal event already.
|
|
1483
|
+
// Either `finish` (natural completion) or any `error` event counts —
|
|
1484
|
+
// the SDK's `wrapEventStream` treats any error as terminal and
|
|
1485
|
+
// appends its own synthetic finish, so synthesizing an abort
|
|
1486
|
+
// terminal on top would duplicate the SDK invariant of one terminal
|
|
1487
|
+
// per turn. The narrower "only abort-coded errors count" predicate
|
|
1488
|
+
// would mis-fire on tripwire/regular errors that race the abort.
|
|
1489
|
+
let sawTerminal = false;
|
|
1237
1490
|
for await (const chunk of this.readableStreamToAsyncGenerator(fullStream)) {
|
|
1238
1491
|
const event = adapter.toChatEvent(chunk);
|
|
1239
1492
|
if (!event)
|
|
1240
1493
|
continue;
|
|
1494
|
+
if (event.type === 'finish' || event.type === 'error') {
|
|
1495
|
+
sawTerminal = true;
|
|
1496
|
+
}
|
|
1241
1497
|
if (isEnrichableToolEvent(event)) {
|
|
1242
1498
|
const toolName = event.type === 'tool-approval-request' ? event.toolCall.toolName : event.toolName;
|
|
1243
1499
|
const metadata = lookupMcpToolMetadata(agentState, toolName);
|
|
@@ -1252,6 +1508,29 @@ export class MastraHarness {
|
|
|
1252
1508
|
}
|
|
1253
1509
|
yield event;
|
|
1254
1510
|
}
|
|
1511
|
+
// The fullStream closed. If abort fired during a suspended-run path
|
|
1512
|
+
// (consumer aborted while a tool-approval was pending), Mastra
|
|
1513
|
+
// releases the prepared run silently — the fullStream just ends with
|
|
1514
|
+
// no `abort` chunk — so synthesize the terminal pair the SDK
|
|
1515
|
+
// invariant requires. See ARCHITECTURE.md → "Tool Approval Flow →
|
|
1516
|
+
// Abort during pending approval (#409)".
|
|
1517
|
+
//
|
|
1518
|
+
// Adapter-coupling note: this gate depends on `MastraEventAdapter`
|
|
1519
|
+
// not yielding a `finish` chunk between `tool-call-approval` and
|
|
1520
|
+
// the consumer's resume. True today (the adapter only emits
|
|
1521
|
+
// `finish` for the terminal Mastra `finish` chunk). A future
|
|
1522
|
+
// Mastra version that injects a step-level `finish` mid-suspend
|
|
1523
|
+
// would set `sawTerminal` early and silently suppress the synth.
|
|
1524
|
+
// If that lands, switch the predicate to track only the terminal
|
|
1525
|
+
// `finish` (e.g. via a per-stream "suspended" flag).
|
|
1526
|
+
if (!sawTerminal && abortSignal?.aborted) {
|
|
1527
|
+
yield {
|
|
1528
|
+
type: 'error',
|
|
1529
|
+
error: new DOMException('The stream was aborted.', 'AbortError'),
|
|
1530
|
+
code: 'abort',
|
|
1531
|
+
};
|
|
1532
|
+
yield { type: 'finish', finishReason: 'error' };
|
|
1533
|
+
}
|
|
1255
1534
|
}
|
|
1256
1535
|
async *readableStreamToAsyncGenerator(stream) {
|
|
1257
1536
|
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.10.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.8.0"
|
|
38
39
|
},
|
|
39
40
|
"peerDependencies": {
|
|
40
|
-
"@salesforce/sfdx-agent-sdk": "0.
|
|
41
|
+
"@salesforce/sfdx-agent-sdk": "0.11.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.11.0",
|
|
45
46
|
"@types/node": "^22.19.17",
|
|
46
47
|
"@vitest/coverage-istanbul": "^4.1.7",
|
|
47
48
|
"@vitest/eslint-plugin": "^1.6.17",
|