@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.
@@ -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
  /**
@@ -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
- * Iterates `agentState.mcpServers` and prefix-matches rather than splitting on
178
- * `_`, because server names themselves may contain underscores
179
- * (`data_pipeline_query_records` → server `data_pipeline`, tool
180
- * `query_records`). Mirrors the catalog access pattern used by
181
- * {@link mapMastraToolsToMcpToolInfo}.
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, so the carry-on-the-record fix —
187
- * attaching an `McpToolInfo` reference (with `serverName`) on the in-memory
188
- * tool-call record from the moment Mastra namespaces the name — becomes
189
- * proportionally more valuable. That fix touches `MastraEventAdapter`,
190
- * `ToolCallInfo`, and the per-stream pipeline; out of scope for W-22693491.
191
- * If a fourth per-event MCP-catalog lookup gets added, do the
192
- * carry-on-the-record refactor first; if this walk shows up in profiles
193
- * before that, build a `Map<namespacedToolName, { serverName, McpToolInfo }>`
194
- * index keyed off discovery completion.
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
- const original = namespacedToolName.slice(serverName.length + 1);
209
- const annotations = serverState.tools[original]
210
- ?.mcp?.annotations;
211
- return { serverName, ...(annotations ? { annotations } : {}) };
218
+ if (!(namespacedToolName in serverState.tools))
219
+ continue;
220
+ if (!best || serverName.length > best.serverName.length) {
221
+ best = { serverName, serverState };
222
+ }
212
223
  }
213
- return undefined;
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
- const startTime = this.clock.now().getTime();
409
- this.buses.emitTelemetry({
410
- type: 'mcp-server-discovery-started',
411
- timestamp: this.clock.now(),
412
- agentId,
413
- serverName,
414
- });
415
- const ready = makeAbortable(client.listToolsetsWithErrors(), options?.abortSignal)
416
- .then(({ toolsets, errors }) => {
417
- const serverError = errors[serverName];
418
- if (serverError) {
419
- throw new Error(serverError);
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
- return this.createThread(agentId);
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, this.buildSummaryPrompt(transcript))
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.destroyThread(agentId, threadId).catch(() => undefined);
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, this.buildSummaryPrompt(chunk));
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, this.buildSummaryPrompt(merged))
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.7.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.4.0",
37
- "@salesforce/llm-gateway-sdk": "0.5.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.8.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.8.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",