@salesforce/sfdx-agent-harness-mastra 0.7.0 → 0.8.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/dist/mastra-harness.d.ts +59 -1
- package/dist/mastra-harness.js +300 -111
- package/package.json +5 -5
package/dist/mastra-harness.d.ts
CHANGED
|
@@ -28,9 +28,16 @@ interface ActiveRun {
|
|
|
28
28
|
/**
|
|
29
29
|
* Per-server MCP state. Each enabled server gets its own MCPClient and
|
|
30
30
|
* independent discovery promise so readiness is tracked individually.
|
|
31
|
+
*
|
|
32
|
+
* `epoch` increments on every fresh discovery (initial create + each
|
|
33
|
+
* reconnect). Discovery callbacks captured against an earlier epoch drop their
|
|
34
|
+
* writes when a newer discovery has already started for the same server, so an
|
|
35
|
+
* orphaned in-flight `listToolsetsWithErrors()` cannot overwrite a fresher
|
|
36
|
+
* discovery's `tools` / `error` / `settled` state.
|
|
31
37
|
*/
|
|
32
38
|
interface McpServerState {
|
|
33
39
|
client: MCPClient;
|
|
40
|
+
epoch: number;
|
|
34
41
|
error?: string;
|
|
35
42
|
ready: Promise<void>;
|
|
36
43
|
settled: boolean;
|
|
@@ -165,6 +172,18 @@ export declare class MastraHarness implements MastraAgentHarness {
|
|
|
165
172
|
*/
|
|
166
173
|
getAgentIds(): Promise<string[]>;
|
|
167
174
|
getMcpServerInfo(agentId: string): McpServerInfo[];
|
|
175
|
+
/**
|
|
176
|
+
* Eager implementation of {@link AgentHarness.reconnectMcpServer}: calls
|
|
177
|
+
* Mastra's `MCPClient.reconnectServer(name)` primitive, supersedes any
|
|
178
|
+
* in-flight discovery (replace-ready semantics — latest call wins), and
|
|
179
|
+
* awaits the new discovery before resolving.
|
|
180
|
+
*
|
|
181
|
+
* Programming errors (unknown agent / unknown server / disabled) reject.
|
|
182
|
+
* Transport / discovery failures are recorded on the per-server state
|
|
183
|
+
* (visible via {@link getMcpServerInfo}) and a `mcp-server-discovery-*`
|
|
184
|
+
* telemetry pair fires; the returned promise still resolves.
|
|
185
|
+
*/
|
|
186
|
+
reconnectMcpServer(agentId: string, serverName: string): Promise<void>;
|
|
168
187
|
/**
|
|
169
188
|
* @requirements
|
|
170
189
|
* - MUST throw an error if the specified `agentId` is not found in `activeAgents`.
|
|
@@ -215,6 +234,14 @@ export declare class MastraHarness implements MastraAgentHarness {
|
|
|
215
234
|
* User turns are treated as authoritative input.
|
|
216
235
|
*/
|
|
217
236
|
compactThread(agentId: string, threadId: string): Promise<string>;
|
|
237
|
+
/**
|
|
238
|
+
* Best-effort wrapper around `destroyThread`: swallows the error so the
|
|
239
|
+
* caller's outer flow isn't aborted by a teardown blip, but logs the
|
|
240
|
+
* failure at warn level so a future regression in `destroyThread` is
|
|
241
|
+
* still observable. Used by `compactThread` on both the empty-source and
|
|
242
|
+
* non-empty paths.
|
|
243
|
+
*/
|
|
244
|
+
private destroyThreadBestEffort;
|
|
218
245
|
/**
|
|
219
246
|
* @requirements
|
|
220
247
|
* - MUST throw an error if the specified `agentId` is not found in `activeAgents`.
|
|
@@ -302,7 +329,6 @@ export declare class MastraHarness implements MastraAgentHarness {
|
|
|
302
329
|
* directly.
|
|
303
330
|
*/
|
|
304
331
|
private summarize;
|
|
305
|
-
private buildSummaryPrompt;
|
|
306
332
|
/**
|
|
307
333
|
* Two-pass summarization for transcripts above the char budget. The
|
|
308
334
|
* transcript is split into roughly-equal chunks each ≤ MAX_TRANSCRIPT_CHARS,
|
|
@@ -317,9 +343,41 @@ export declare class MastraHarness implements MastraAgentHarness {
|
|
|
317
343
|
*/
|
|
318
344
|
private splitIntoChunks;
|
|
319
345
|
private getAgentStateOrThrow;
|
|
346
|
+
/**
|
|
347
|
+
* Runs MCP discovery for one server: emits the
|
|
348
|
+
* `mcp-server-discovery-started` / `…-completed` / `…-failed` telemetry
|
|
349
|
+
* triple and writes results into the corresponding entry in `serverStates`.
|
|
350
|
+
*
|
|
351
|
+
* The caller MUST `set()` the entry into `serverStates` either before
|
|
352
|
+
* calling this helper (the `createAgent` path constructs the entry
|
|
353
|
+
* synchronously after this returns the promise) or before any await
|
|
354
|
+
* boundary in the helper's chain — the chain reads back via
|
|
355
|
+
* `serverStates.get(serverName)!` to write `tools` / `error`. Both call
|
|
356
|
+
* sites today put the entry in place before the first microtask boundary.
|
|
357
|
+
*
|
|
358
|
+
* Errors observed during discovery are recorded on the entry's `error`
|
|
359
|
+
* field; the returned promise resolves either way (it only rejects when
|
|
360
|
+
* an aborted operation throws after we have already cleaned up). The
|
|
361
|
+
* `settled` flag is pinned by `.finally()` so `getMcpServerInfo()` can
|
|
362
|
+
* tell `Connecting` from `Connected`/`Error`.
|
|
363
|
+
*/
|
|
364
|
+
private runMcpDiscovery;
|
|
320
365
|
private toChatStreamResult;
|
|
321
366
|
private getMemoryOrThrow;
|
|
322
367
|
private agentResourceId;
|
|
368
|
+
/**
|
|
369
|
+
* Build a `RequestContext` carrying the thread + resource ids under the
|
|
370
|
+
* reserved keys Mastra reads when input processors (e.g. `ToolSearchProcessor`,
|
|
371
|
+
* `SkillSearchProcessor`) call `requestContext.get(MASTRA_THREAD_ID_KEY)`.
|
|
372
|
+
*
|
|
373
|
+
* `memory: { thread, resource }` populates Mastra's memory layer but is NOT
|
|
374
|
+
* propagated into the request context, so processors that key per-thread
|
|
375
|
+
* state by `MASTRA_THREAD_ID_KEY` would otherwise see `undefined` and fall
|
|
376
|
+
* back to the `'default'` bucket — folding every consumer thread into one.
|
|
377
|
+
* Surfacing the ids here lets `ToolSearchHandle.clearState(threadId)`
|
|
378
|
+
* actually match what the processor stored.
|
|
379
|
+
*/
|
|
380
|
+
private buildRequestContext;
|
|
323
381
|
private createEventStream;
|
|
324
382
|
private readableStreamToAsyncGenerator;
|
|
325
383
|
/**
|
package/dist/mastra-harness.js
CHANGED
|
@@ -8,6 +8,7 @@ import { Agent } from '@mastra/core/agent';
|
|
|
8
8
|
import { Mastra } from '@mastra/core/mastra';
|
|
9
9
|
import { Memory } from '@mastra/memory';
|
|
10
10
|
import { SkillSearchProcessor, ToolSearchProcessor } from '@mastra/core/processors';
|
|
11
|
+
import { MASTRA_RESOURCE_ID_KEY, MASTRA_THREAD_ID_KEY, RequestContext } from '@mastra/core/request-context';
|
|
11
12
|
import { LibSQLStore } from '@mastra/libsql';
|
|
12
13
|
import { MCPClient } from '@mastra/mcp';
|
|
13
14
|
import { AgentSDKError, AgentSDKErrorType, DEFAULT_MAX_STEPS, HarnessBusOwner, McpServerStatus, } from '@salesforce/sfdx-agent-sdk';
|
|
@@ -18,7 +19,7 @@ import { mapToolDefinitionsToMastraTools } from './mastra-tool-mapper.js';
|
|
|
18
19
|
import { hasEnabledServers, mapToMastraMCPServers } from './mcp-config-mapper.js';
|
|
19
20
|
import { DefaultMastraModelConfigFactory } from './default-model-config-factory.js';
|
|
20
21
|
import { partitionPool, } from './tool-search.js';
|
|
21
|
-
import { Clock, getErrorMessage, RealClock, splitFrontmatterAndBody, UUIDGenerator, } from '@salesforce/agentic-common';
|
|
22
|
+
import { buildSummaryPrompt, Clock, getErrorMessage, RealClock, splitFrontmatterAndBody, UUIDGenerator, } from '@salesforce/agentic-common';
|
|
22
23
|
import { makeAbortable } from './utils.js';
|
|
23
24
|
import { Workspace, LocalFilesystem, LocalSandbox, WORKSPACE_TOOLS, WORKSPACE_TOOLS_PREFIX, } from '@mastra/core/workspace';
|
|
24
25
|
/**
|
|
@@ -168,49 +169,62 @@ async function listMarkdownFilesInDirectory(dir) {
|
|
|
168
169
|
}
|
|
169
170
|
return names.sort().map((name) => path.join(dir, name));
|
|
170
171
|
}
|
|
172
|
+
/**
|
|
173
|
+
* Tool ChatEvents that carry the optional `serverName` + `annotations`
|
|
174
|
+
* enrichment fields. Centralizing the predicate lets `createEventStream`
|
|
175
|
+
* narrow once and apply the lookup uniformly across the three variants.
|
|
176
|
+
*/
|
|
177
|
+
function isEnrichableToolEvent(event) {
|
|
178
|
+
return event.type === 'tool-call' || event.type === 'tool-result' || event.type === 'tool-approval-request';
|
|
179
|
+
}
|
|
171
180
|
/**
|
|
172
181
|
* Resolve the MCP server name and (optionally) annotations for a Mastra
|
|
173
182
|
* namespaced tool name (`${serverName}_${toolName}`). Returns `undefined` for
|
|
174
183
|
* tools that are not in any MCP server's catalog (consumer-declared client
|
|
175
184
|
* tools, workspace tools).
|
|
176
185
|
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
* (`
|
|
180
|
-
* `
|
|
181
|
-
*
|
|
186
|
+
* Selects the **longest matching prefix whose catalog actually contains the
|
|
187
|
+
* tool**. Prefix matching alone is unsafe when two server names are
|
|
188
|
+
* prefix-related (e.g. OOTB `salesforce_dx` and a user-config
|
|
189
|
+
* `salesforce_dx_custom`): naive iteration would attribute every
|
|
190
|
+
* `salesforce_dx_custom_*` call to the OOTB server because `Map` iteration is
|
|
191
|
+
* insertion order and OOTB usually registers first. The existence check on the
|
|
192
|
+
* candidate's `tools` map closes that hole; the longest-prefix tiebreak handles
|
|
193
|
+
* the case where two prefix-related servers both legitimately catalog a tool
|
|
194
|
+
* with the same suffix.
|
|
195
|
+
*
|
|
196
|
+
* `serverState.tools` is keyed by the **namespaced** name (see the
|
|
197
|
+
* `namespacedTools[\`${serverName}_${toolName}\`] = toolConfig` line in
|
|
198
|
+
* `createAgent`), so the existence check uses `namespacedToolName` directly.
|
|
182
199
|
*
|
|
183
200
|
* Design debt: this walk runs per `tool-call` / `tool-result` /
|
|
184
201
|
* `tool-approval-request` event and reconstructs the tool→server linkage from
|
|
185
202
|
* the namespaced string. The cost is O(servers) on a typically-small map, but
|
|
186
|
-
* three call sites now share the same lookup
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*/
|
|
196
|
-
/**
|
|
197
|
-
* Tool ChatEvents that carry the optional `serverName` + `annotations`
|
|
198
|
-
* enrichment fields. Centralizing the predicate lets `createEventStream`
|
|
199
|
-
* narrow once and apply the lookup uniformly across the three variants.
|
|
203
|
+
* three call sites now share the same lookup and consumers (e.g. AFV's W4.8
|
|
204
|
+
* trust policy) consume the enriched `serverName` for trust-relevant
|
|
205
|
+
* decisions, so the carry-on-the-record fix — attaching an `McpToolInfo`
|
|
206
|
+
* reference (with `serverName`) on the in-memory tool-call record from the
|
|
207
|
+
* moment Mastra namespaces the name — becomes proportionally more valuable.
|
|
208
|
+
* That fix touches `MastraEventAdapter`, `ToolCallInfo`, and the per-stream
|
|
209
|
+
* pipeline. If this walk shows up in profiles before then, build a
|
|
210
|
+
* `Map<namespacedToolName, { serverName, McpToolInfo }>` index keyed off
|
|
211
|
+
* discovery completion.
|
|
200
212
|
*/
|
|
201
|
-
function isEnrichableToolEvent(event) {
|
|
202
|
-
return event.type === 'tool-call' || event.type === 'tool-result' || event.type === 'tool-approval-request';
|
|
203
|
-
}
|
|
204
213
|
function lookupMcpToolMetadata(agentState, namespacedToolName) {
|
|
214
|
+
let best;
|
|
205
215
|
for (const [serverName, serverState] of agentState.mcpServers) {
|
|
206
216
|
if (!namespacedToolName.startsWith(`${serverName}_`))
|
|
207
217
|
continue;
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
218
|
+
if (!(namespacedToolName in serverState.tools))
|
|
219
|
+
continue;
|
|
220
|
+
if (!best || serverName.length > best.serverName.length) {
|
|
221
|
+
best = { serverName, serverState };
|
|
222
|
+
}
|
|
212
223
|
}
|
|
213
|
-
|
|
224
|
+
if (!best)
|
|
225
|
+
return undefined;
|
|
226
|
+
const annotations = best.serverState.tools[namespacedToolName]?.mcp?.annotations;
|
|
227
|
+
return { serverName: best.serverName, ...(annotations ? { annotations } : {}) };
|
|
214
228
|
}
|
|
215
229
|
/**
|
|
216
230
|
* Projects the namespaced Mastra `ToolAction` map for one MCP server into the
|
|
@@ -405,78 +419,18 @@ export class MastraHarness {
|
|
|
405
419
|
// Fallback only — per-server MCPServerConfig.timeout (in serverDef) takes precedence.
|
|
406
420
|
timeout: DEFAULT_MCP_TIMEOUT_MS,
|
|
407
421
|
});
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
}
|
|
421
|
-
const tools = toolsets[serverName] ?? {};
|
|
422
|
-
const namespacedTools = {};
|
|
423
|
-
for (const [toolName, toolConfig] of Object.entries(tools)) {
|
|
424
|
-
namespacedTools[`${serverName}_${toolName}`] = toolConfig;
|
|
425
|
-
}
|
|
426
|
-
mcpServerStates.get(serverName).tools = namespacedTools;
|
|
427
|
-
// listToolsetsWithErrors() runs in the background and can resolve
|
|
428
|
-
// after `shutdown()` has disposed the buses. Swallow only `DISPOSED`
|
|
429
|
-
// so a real listener exception still surfaces.
|
|
430
|
-
try {
|
|
431
|
-
this.buses.emitTelemetry({
|
|
432
|
-
type: 'mcp-server-discovery-completed',
|
|
433
|
-
timestamp: this.clock.now(),
|
|
434
|
-
agentId,
|
|
435
|
-
serverName,
|
|
436
|
-
toolCount: Object.keys(namespacedTools).length,
|
|
437
|
-
durationMs: this.clock.now().getTime() - startTime,
|
|
438
|
-
});
|
|
439
|
-
}
|
|
440
|
-
catch (emitErr) {
|
|
441
|
-
if (!isDisposedError(emitErr))
|
|
442
|
-
throw emitErr;
|
|
443
|
-
}
|
|
444
|
-
})
|
|
445
|
-
.catch(async (err) => {
|
|
446
|
-
if (options?.abortSignal?.aborted) {
|
|
447
|
-
await client.disconnect().catch(() => undefined);
|
|
448
|
-
mcpServerStates.delete(serverName);
|
|
449
|
-
}
|
|
450
|
-
else {
|
|
451
|
-
const s = mcpServerStates.get(serverName);
|
|
452
|
-
if (s)
|
|
453
|
-
s.error = err instanceof Error ? err.message : String(err);
|
|
454
|
-
this.buses.getLogBus()?.warn('MCP server discovery failed', {
|
|
455
|
-
serverName,
|
|
456
|
-
error: err instanceof Error ? err.message : String(err),
|
|
457
|
-
});
|
|
458
|
-
try {
|
|
459
|
-
this.buses.emitTelemetry({
|
|
460
|
-
type: 'mcp-server-discovery-failed',
|
|
461
|
-
timestamp: this.clock.now(),
|
|
462
|
-
agentId,
|
|
463
|
-
serverName,
|
|
464
|
-
durationMs: this.clock.now().getTime() - startTime,
|
|
465
|
-
error: err instanceof Error ? err : new Error(String(err)),
|
|
466
|
-
});
|
|
467
|
-
}
|
|
468
|
-
catch (emitErr) {
|
|
469
|
-
if (!isDisposedError(emitErr))
|
|
470
|
-
throw emitErr;
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
})
|
|
474
|
-
.finally(() => {
|
|
475
|
-
const s = mcpServerStates.get(serverName);
|
|
476
|
-
if (s)
|
|
477
|
-
s.settled = true;
|
|
478
|
-
});
|
|
479
|
-
mcpServerStates.set(serverName, { client, ready, settled: false, tools: {} });
|
|
422
|
+
// Place the entry first so runMcpDiscovery's epoch-gated closures
|
|
423
|
+
// see a matching epoch on first lookup. The promise chain only runs
|
|
424
|
+
// after the synchronous `set` and `ready` assignment complete.
|
|
425
|
+
const entry = {
|
|
426
|
+
client,
|
|
427
|
+
epoch: 0,
|
|
428
|
+
ready: Promise.resolve(),
|
|
429
|
+
settled: false,
|
|
430
|
+
tools: {},
|
|
431
|
+
};
|
|
432
|
+
mcpServerStates.set(serverName, entry);
|
|
433
|
+
entry.ready = this.runMcpDiscovery(agentId, serverName, client, mcpServerStates, 0, options?.abortSignal);
|
|
480
434
|
}
|
|
481
435
|
}
|
|
482
436
|
const customTools = config.tools?.length ? mapToolDefinitionsToMastraTools(config.tools) : {};
|
|
@@ -645,6 +599,87 @@ export class MastraHarness {
|
|
|
645
599
|
}
|
|
646
600
|
return results;
|
|
647
601
|
}
|
|
602
|
+
/**
|
|
603
|
+
* Eager implementation of {@link AgentHarness.reconnectMcpServer}: calls
|
|
604
|
+
* Mastra's `MCPClient.reconnectServer(name)` primitive, supersedes any
|
|
605
|
+
* in-flight discovery (replace-ready semantics — latest call wins), and
|
|
606
|
+
* awaits the new discovery before resolving.
|
|
607
|
+
*
|
|
608
|
+
* Programming errors (unknown agent / unknown server / disabled) reject.
|
|
609
|
+
* Transport / discovery failures are recorded on the per-server state
|
|
610
|
+
* (visible via {@link getMcpServerInfo}) and a `mcp-server-discovery-*`
|
|
611
|
+
* telemetry pair fires; the returned promise still resolves.
|
|
612
|
+
*/
|
|
613
|
+
async reconnectMcpServer(agentId, serverName) {
|
|
614
|
+
const state = this.getAgentStateOrThrow(agentId);
|
|
615
|
+
const serverState = state.mcpServers.get(serverName);
|
|
616
|
+
if (!serverState) {
|
|
617
|
+
const disabled = state.mcpConfig?.[serverName]?.enabled === false;
|
|
618
|
+
throw new AgentSDKError(disabled
|
|
619
|
+
? `MCP server "${serverName}" is disabled on agent "${agentId}"; reconnect is not valid for disabled servers`
|
|
620
|
+
: `MCP server "${serverName}" is not configured on agent "${agentId}"`, disabled ? AgentSDKErrorType.MCP_SERVER_DISABLED : AgentSDKErrorType.MCP_SERVER_NOT_FOUND);
|
|
621
|
+
}
|
|
622
|
+
// Replace-ready: bump the epoch first so any in-flight discovery from a
|
|
623
|
+
// previous epoch will drop its writes when its `currentEntry()` lookup fails.
|
|
624
|
+
// Then reset visible state for the fresh attempt.
|
|
625
|
+
const epoch = serverState.epoch + 1;
|
|
626
|
+
serverState.epoch = epoch;
|
|
627
|
+
serverState.error = undefined;
|
|
628
|
+
serverState.tools = {};
|
|
629
|
+
serverState.settled = false;
|
|
630
|
+
// Emit `started` BEFORE awaiting the transport reconnect so subscribers
|
|
631
|
+
// see started → (completed | failed) timestamps in chronological order.
|
|
632
|
+
// The previous shape had `started` emitted from the catch block AFTER
|
|
633
|
+
// `reconnectServer()` had already thrown — making the started timestamp
|
|
634
|
+
// appear later than the failure event.
|
|
635
|
+
const startTime = this.clock.now().getTime();
|
|
636
|
+
this.buses.emitTelemetry({
|
|
637
|
+
type: 'mcp-server-discovery-started',
|
|
638
|
+
timestamp: this.clock.now(),
|
|
639
|
+
agentId,
|
|
640
|
+
serverName,
|
|
641
|
+
});
|
|
642
|
+
// Transport-level reconnect can throw (stdio spawn failure, remote handshake
|
|
643
|
+
// failure). Record on serverState and emit only `failed` (not started+failed
|
|
644
|
+
// — `started` already fired above). This keeps the public contract
|
|
645
|
+
// ("rejects only on programming errors") honest.
|
|
646
|
+
try {
|
|
647
|
+
await serverState.client.reconnectServer(serverName);
|
|
648
|
+
}
|
|
649
|
+
catch (err) {
|
|
650
|
+
// Drop telemetry + state writes if a newer epoch has superseded us.
|
|
651
|
+
if (state.mcpServers.get(serverName)?.epoch !== epoch)
|
|
652
|
+
return;
|
|
653
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
654
|
+
serverState.error = message;
|
|
655
|
+
serverState.settled = true;
|
|
656
|
+
this.buses.getLogBus()?.warn('MCP server reconnect failed', {
|
|
657
|
+
serverName,
|
|
658
|
+
error: message,
|
|
659
|
+
});
|
|
660
|
+
try {
|
|
661
|
+
this.buses.emitTelemetry({
|
|
662
|
+
type: 'mcp-server-discovery-failed',
|
|
663
|
+
timestamp: this.clock.now(),
|
|
664
|
+
agentId,
|
|
665
|
+
serverName,
|
|
666
|
+
durationMs: this.clock.now().getTime() - startTime,
|
|
667
|
+
error: err instanceof Error ? err : new Error(message),
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
catch (emitErr) {
|
|
671
|
+
if (!isDisposedError(emitErr))
|
|
672
|
+
throw emitErr;
|
|
673
|
+
}
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
// Pass the existing startTime so runMcpDiscovery does NOT emit its own
|
|
677
|
+
// `started` (we already did) and the eventual completed/failed event's
|
|
678
|
+
// durationMs covers the full reconnect+discovery span — one `started`,
|
|
679
|
+
// one terminal event per public reconnect call.
|
|
680
|
+
serverState.ready = this.runMcpDiscovery(agentId, serverName, serverState.client, state.mcpServers, epoch, undefined, startTime);
|
|
681
|
+
await serverState.ready;
|
|
682
|
+
}
|
|
648
683
|
// ── Thread (Chat Session) Lifecycle ──────────────────────────────
|
|
649
684
|
/**
|
|
650
685
|
* @requirements
|
|
@@ -724,7 +759,12 @@ export class MastraHarness {
|
|
|
724
759
|
this.getAgentStateOrThrow(agentId);
|
|
725
760
|
const messages = await this.getMessages(agentId, threadId);
|
|
726
761
|
if (messages.length === 0) {
|
|
727
|
-
|
|
762
|
+
const newThreadId = await this.createThread(agentId);
|
|
763
|
+
// Empty source still gets compacted away — leaving it attached
|
|
764
|
+
// would be a leak. Mirrors the non-empty path's destroyThread
|
|
765
|
+
// call below; both paths exit with the source detached.
|
|
766
|
+
await this.destroyThreadBestEffort(agentId, threadId);
|
|
767
|
+
return newThreadId;
|
|
728
768
|
}
|
|
729
769
|
const transcript = messages
|
|
730
770
|
.map((m) => {
|
|
@@ -735,7 +775,7 @@ export class MastraHarness {
|
|
|
735
775
|
let newThreadId;
|
|
736
776
|
try {
|
|
737
777
|
const summaryText = transcript.length <= MAX_TRANSCRIPT_CHARS
|
|
738
|
-
? await this.summarize(agentId,
|
|
778
|
+
? await this.summarize(agentId, buildSummaryPrompt(transcript))
|
|
739
779
|
: await this.summarizeInChunks(agentId, transcript);
|
|
740
780
|
newThreadId = await this.createThread(agentId);
|
|
741
781
|
const userTs = this.clock.now();
|
|
@@ -761,9 +801,26 @@ export class MastraHarness {
|
|
|
761
801
|
// Source-thread cleanup is best-effort. The user's data is already
|
|
762
802
|
// safely seeded into newThreadId; a failed delete leaves an orphan
|
|
763
803
|
// but does not invalidate the compaction outcome.
|
|
764
|
-
await this.
|
|
804
|
+
await this.destroyThreadBestEffort(agentId, threadId);
|
|
765
805
|
return newThreadId;
|
|
766
806
|
}
|
|
807
|
+
/**
|
|
808
|
+
* Best-effort wrapper around `destroyThread`: swallows the error so the
|
|
809
|
+
* caller's outer flow isn't aborted by a teardown blip, but logs the
|
|
810
|
+
* failure at warn level so a future regression in `destroyThread` is
|
|
811
|
+
* still observable. Used by `compactThread` on both the empty-source and
|
|
812
|
+
* non-empty paths.
|
|
813
|
+
*/
|
|
814
|
+
async destroyThreadBestEffort(agentId, threadId) {
|
|
815
|
+
try {
|
|
816
|
+
await this.destroyThread(agentId, threadId);
|
|
817
|
+
}
|
|
818
|
+
catch (err) {
|
|
819
|
+
this.buses
|
|
820
|
+
.getLogBus()
|
|
821
|
+
?.warn(`Best-effort destroyThread failed during compactThread for thread "${threadId}" on agent "${agentId}": ${getErrorMessage(err)}`, { agentId, threadId }, err instanceof Error ? err : new Error(String(err)));
|
|
822
|
+
}
|
|
823
|
+
}
|
|
767
824
|
// ── Core Messaging ───────────────────────────────────────────────
|
|
768
825
|
/**
|
|
769
826
|
* @requirements
|
|
@@ -781,6 +838,7 @@ export class MastraHarness {
|
|
|
781
838
|
thread: threadId,
|
|
782
839
|
resource: this.agentResourceId(agentId),
|
|
783
840
|
},
|
|
841
|
+
requestContext: this.buildRequestContext(agentId, threadId),
|
|
784
842
|
maxSteps: options?.maxSteps ?? DEFAULT_MAX_STEPS,
|
|
785
843
|
abortSignal: options?.abortSignal,
|
|
786
844
|
requireToolApproval: options?.requireToolApproval,
|
|
@@ -818,6 +876,7 @@ export class MastraHarness {
|
|
|
818
876
|
thread: threadId,
|
|
819
877
|
resource: this.agentResourceId(agentId),
|
|
820
878
|
},
|
|
879
|
+
requestContext: this.buildRequestContext(agentId, threadId),
|
|
821
880
|
});
|
|
822
881
|
return this.toChatStreamResult(agentState, threadId, output);
|
|
823
882
|
}
|
|
@@ -855,6 +914,7 @@ export class MastraHarness {
|
|
|
855
914
|
thread: threadId,
|
|
856
915
|
resource: this.agentResourceId(agentId),
|
|
857
916
|
},
|
|
917
|
+
requestContext: this.buildRequestContext(agentId, threadId),
|
|
858
918
|
});
|
|
859
919
|
return this.toChatStreamResult(agentState, threadId, output);
|
|
860
920
|
}
|
|
@@ -886,6 +946,7 @@ export class MastraHarness {
|
|
|
886
946
|
thread: threadId,
|
|
887
947
|
resource: this.agentResourceId(agentId),
|
|
888
948
|
},
|
|
949
|
+
requestContext: this.buildRequestContext(agentId, threadId),
|
|
889
950
|
});
|
|
890
951
|
return this.toChatStreamResult(agentState, threadId, output);
|
|
891
952
|
}
|
|
@@ -949,15 +1010,14 @@ export class MastraHarness {
|
|
|
949
1010
|
*/
|
|
950
1011
|
async summarize(agentId, prompt) {
|
|
951
1012
|
const agentState = this.getAgentStateOrThrow(agentId);
|
|
1013
|
+
// No `requestContext` here on purpose: this is a thread-less one-shot
|
|
1014
|
+
// generate, so per-thread input processors (e.g. ToolSearchProcessor)
|
|
1015
|
+
// harmlessly fall back to the `'default'` bucket. The "every streaming
|
|
1016
|
+
// entry-point passes a RequestContext" invariant in ARCHITECTURE.md
|
|
1017
|
+
// applies to thread-bound entry points only.
|
|
952
1018
|
const result = await agentState.agent.generate(prompt, { maxSteps: 1 });
|
|
953
1019
|
return result.text;
|
|
954
1020
|
}
|
|
955
|
-
buildSummaryPrompt(transcript) {
|
|
956
|
-
return ('Summarize the following conversation into a concise context summary. ' +
|
|
957
|
-
'Preserve key decisions, facts, and action items. ' +
|
|
958
|
-
'Write in third person as a factual record.\n\n' +
|
|
959
|
-
transcript);
|
|
960
|
-
}
|
|
961
1021
|
/**
|
|
962
1022
|
* Two-pass summarization for transcripts above the char budget. The
|
|
963
1023
|
* transcript is split into roughly-equal chunks each ≤ MAX_TRANSCRIPT_CHARS,
|
|
@@ -968,7 +1028,7 @@ export class MastraHarness {
|
|
|
968
1028
|
const chunks = this.splitIntoChunks(transcript, MAX_TRANSCRIPT_CHARS);
|
|
969
1029
|
const chunkSummaries = [];
|
|
970
1030
|
for (const chunk of chunks) {
|
|
971
|
-
const summary = await this.summarize(agentId,
|
|
1031
|
+
const summary = await this.summarize(agentId, buildSummaryPrompt(chunk));
|
|
972
1032
|
chunkSummaries.push(summary);
|
|
973
1033
|
}
|
|
974
1034
|
const merged = chunkSummaries.map((s, i) => `Part ${i + 1}:\n${s}`).join('\n\n');
|
|
@@ -976,7 +1036,7 @@ export class MastraHarness {
|
|
|
976
1036
|
// long chat could in principle still exceed budget. If so, recurse;
|
|
977
1037
|
// otherwise final-summarize.
|
|
978
1038
|
return merged.length <= MAX_TRANSCRIPT_CHARS
|
|
979
|
-
? this.summarize(agentId,
|
|
1039
|
+
? this.summarize(agentId, buildSummaryPrompt(merged))
|
|
980
1040
|
: this.summarizeInChunks(agentId, merged);
|
|
981
1041
|
}
|
|
982
1042
|
/**
|
|
@@ -1020,6 +1080,117 @@ export class MastraHarness {
|
|
|
1020
1080
|
}
|
|
1021
1081
|
return state;
|
|
1022
1082
|
}
|
|
1083
|
+
/**
|
|
1084
|
+
* Runs MCP discovery for one server: emits the
|
|
1085
|
+
* `mcp-server-discovery-started` / `…-completed` / `…-failed` telemetry
|
|
1086
|
+
* triple and writes results into the corresponding entry in `serverStates`.
|
|
1087
|
+
*
|
|
1088
|
+
* The caller MUST `set()` the entry into `serverStates` either before
|
|
1089
|
+
* calling this helper (the `createAgent` path constructs the entry
|
|
1090
|
+
* synchronously after this returns the promise) or before any await
|
|
1091
|
+
* boundary in the helper's chain — the chain reads back via
|
|
1092
|
+
* `serverStates.get(serverName)!` to write `tools` / `error`. Both call
|
|
1093
|
+
* sites today put the entry in place before the first microtask boundary.
|
|
1094
|
+
*
|
|
1095
|
+
* Errors observed during discovery are recorded on the entry's `error`
|
|
1096
|
+
* field; the returned promise resolves either way (it only rejects when
|
|
1097
|
+
* an aborted operation throws after we have already cleaned up). The
|
|
1098
|
+
* `settled` flag is pinned by `.finally()` so `getMcpServerInfo()` can
|
|
1099
|
+
* tell `Connecting` from `Connected`/`Error`.
|
|
1100
|
+
*/
|
|
1101
|
+
runMcpDiscovery(agentId, serverName, client, serverStates, epoch, abortSignal,
|
|
1102
|
+
// When set, reuse the caller's startTime and skip emitting `started` —
|
|
1103
|
+
// the caller (e.g. reconnectMcpServer) has already emitted it. The
|
|
1104
|
+
// resulting completed/failed event still uses the caller's startTime
|
|
1105
|
+
// for `durationMs`, so subscribers see one started → terminal pair.
|
|
1106
|
+
existingStartTime) {
|
|
1107
|
+
const startTime = existingStartTime ?? this.clock.now().getTime();
|
|
1108
|
+
if (existingStartTime === undefined) {
|
|
1109
|
+
this.buses.emitTelemetry({
|
|
1110
|
+
type: 'mcp-server-discovery-started',
|
|
1111
|
+
timestamp: this.clock.now(),
|
|
1112
|
+
agentId,
|
|
1113
|
+
serverName,
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
// Returns the entry if and only if it exists AND still belongs to this
|
|
1117
|
+
// epoch. If a fresher discovery has bumped the epoch, this discovery's
|
|
1118
|
+
// writes / telemetry are dropped to prevent orphan-write races with
|
|
1119
|
+
// reconnect's replace-ready semantics.
|
|
1120
|
+
const currentEntry = () => {
|
|
1121
|
+
const s = serverStates.get(serverName);
|
|
1122
|
+
return s && s.epoch === epoch ? s : undefined;
|
|
1123
|
+
};
|
|
1124
|
+
return makeAbortable(client.listToolsetsWithErrors(), abortSignal)
|
|
1125
|
+
.then(({ toolsets, errors }) => {
|
|
1126
|
+
const serverError = errors[serverName];
|
|
1127
|
+
if (serverError) {
|
|
1128
|
+
throw new Error(serverError);
|
|
1129
|
+
}
|
|
1130
|
+
const tools = toolsets[serverName] ?? {};
|
|
1131
|
+
const namespacedTools = {};
|
|
1132
|
+
for (const [toolName, toolConfig] of Object.entries(tools)) {
|
|
1133
|
+
namespacedTools[`${serverName}_${toolName}`] = toolConfig;
|
|
1134
|
+
}
|
|
1135
|
+
const s = currentEntry();
|
|
1136
|
+
if (!s)
|
|
1137
|
+
return; // superseded — drop write + telemetry
|
|
1138
|
+
s.tools = namespacedTools;
|
|
1139
|
+
// listToolsetsWithErrors() runs in the background and can resolve
|
|
1140
|
+
// after `shutdown()` has disposed the buses. Swallow only `DISPOSED`
|
|
1141
|
+
// so a real listener exception still surfaces.
|
|
1142
|
+
try {
|
|
1143
|
+
this.buses.emitTelemetry({
|
|
1144
|
+
type: 'mcp-server-discovery-completed',
|
|
1145
|
+
timestamp: this.clock.now(),
|
|
1146
|
+
agentId,
|
|
1147
|
+
serverName,
|
|
1148
|
+
toolCount: Object.keys(namespacedTools).length,
|
|
1149
|
+
durationMs: this.clock.now().getTime() - startTime,
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
catch (emitErr) {
|
|
1153
|
+
if (!isDisposedError(emitErr))
|
|
1154
|
+
throw emitErr;
|
|
1155
|
+
}
|
|
1156
|
+
})
|
|
1157
|
+
.catch(async (err) => {
|
|
1158
|
+
if (abortSignal?.aborted) {
|
|
1159
|
+
await client.disconnect().catch(() => undefined);
|
|
1160
|
+
if (currentEntry())
|
|
1161
|
+
serverStates.delete(serverName);
|
|
1162
|
+
}
|
|
1163
|
+
else {
|
|
1164
|
+
const s = currentEntry();
|
|
1165
|
+
if (!s)
|
|
1166
|
+
return; // superseded — drop write + telemetry
|
|
1167
|
+
s.error = err instanceof Error ? err.message : String(err);
|
|
1168
|
+
this.buses.getLogBus()?.warn('MCP server discovery failed', {
|
|
1169
|
+
serverName,
|
|
1170
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1171
|
+
});
|
|
1172
|
+
try {
|
|
1173
|
+
this.buses.emitTelemetry({
|
|
1174
|
+
type: 'mcp-server-discovery-failed',
|
|
1175
|
+
timestamp: this.clock.now(),
|
|
1176
|
+
agentId,
|
|
1177
|
+
serverName,
|
|
1178
|
+
durationMs: this.clock.now().getTime() - startTime,
|
|
1179
|
+
error: err instanceof Error ? err : new Error(String(err)),
|
|
1180
|
+
});
|
|
1181
|
+
}
|
|
1182
|
+
catch (emitErr) {
|
|
1183
|
+
if (!isDisposedError(emitErr))
|
|
1184
|
+
throw emitErr;
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
})
|
|
1188
|
+
.finally(() => {
|
|
1189
|
+
const s = currentEntry();
|
|
1190
|
+
if (s)
|
|
1191
|
+
s.settled = true;
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1023
1194
|
toChatStreamResult(agentState, threadId, output) {
|
|
1024
1195
|
// Entries are keyed by threadId so each thread holds at most one entry.
|
|
1025
1196
|
// Stale entries are overwritten here on the next stream; explicit cleanup
|
|
@@ -1044,6 +1215,24 @@ export class MastraHarness {
|
|
|
1044
1215
|
agentResourceId(agentId) {
|
|
1045
1216
|
return `AGENT_INSTANCE:${agentId}`;
|
|
1046
1217
|
}
|
|
1218
|
+
/**
|
|
1219
|
+
* Build a `RequestContext` carrying the thread + resource ids under the
|
|
1220
|
+
* reserved keys Mastra reads when input processors (e.g. `ToolSearchProcessor`,
|
|
1221
|
+
* `SkillSearchProcessor`) call `requestContext.get(MASTRA_THREAD_ID_KEY)`.
|
|
1222
|
+
*
|
|
1223
|
+
* `memory: { thread, resource }` populates Mastra's memory layer but is NOT
|
|
1224
|
+
* propagated into the request context, so processors that key per-thread
|
|
1225
|
+
* state by `MASTRA_THREAD_ID_KEY` would otherwise see `undefined` and fall
|
|
1226
|
+
* back to the `'default'` bucket — folding every consumer thread into one.
|
|
1227
|
+
* Surfacing the ids here lets `ToolSearchHandle.clearState(threadId)`
|
|
1228
|
+
* actually match what the processor stored.
|
|
1229
|
+
*/
|
|
1230
|
+
buildRequestContext(agentId, threadId) {
|
|
1231
|
+
const ctx = new RequestContext();
|
|
1232
|
+
ctx.set(MASTRA_THREAD_ID_KEY, threadId);
|
|
1233
|
+
ctx.set(MASTRA_RESOURCE_ID_KEY, this.agentResourceId(agentId));
|
|
1234
|
+
return ctx;
|
|
1235
|
+
}
|
|
1047
1236
|
async *createEventStream(fullStream, adapter, agentState) {
|
|
1048
1237
|
for await (const chunk of this.readableStreamToAsyncGenerator(fullStream)) {
|
|
1049
1238
|
const event = adapter.toChatEvent(chunk);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@salesforce/sfdx-agent-harness-mastra",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Mastra-backed AgentHarness implementation for @salesforce/sfdx-agent-sdk",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -33,15 +33,15 @@
|
|
|
33
33
|
"@mastra/mcp": "^1.8.0",
|
|
34
34
|
"@mastra/memory": "^1.19.0",
|
|
35
35
|
"@mastra/schema-compat": "^1.2.10",
|
|
36
|
-
"@salesforce/agentic-common": "0.
|
|
37
|
-
"@salesforce/llm-gateway-sdk": "0.
|
|
36
|
+
"@salesforce/agentic-common": "0.5.0",
|
|
37
|
+
"@salesforce/llm-gateway-sdk": "0.6.0"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
|
-
"@salesforce/sfdx-agent-sdk": "0.
|
|
40
|
+
"@salesforce/sfdx-agent-sdk": "0.9.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@eslint/js": "^10.0.1",
|
|
44
|
-
"@salesforce/sfdx-agent-sdk": "0.
|
|
44
|
+
"@salesforce/sfdx-agent-sdk": "0.9.0",
|
|
45
45
|
"@types/node": "^22.19.17",
|
|
46
46
|
"@vitest/coverage-istanbul": "^4.1.7",
|
|
47
47
|
"@vitest/eslint-plugin": "^1.6.17",
|