@salesforce/sfdx-agent-harness-mastra 0.6.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/README.md CHANGED
@@ -109,6 +109,30 @@ Mastra-internal pool / index / loaded-tools state is intentionally not on the ha
109
109
  are `getStateStats()`, `clearState(threadId?)`, `clearAllState()`, and `cleanupNow()` on `ToolSearchHandle`, plus `id`
110
110
  on both handles.
111
111
 
112
+ ### MCP tool-event enrichment
113
+
114
+ The harness enriches every `tool-call`, `tool-result`, and `tool-approval-request` event with the originating MCP server
115
+ name and any annotations the server declared, so consumers can branch on tool hints or group by server without parsing
116
+ the namespaced tool name themselves. `serverName` is `undefined` for non-MCP tools (workspace tools, consumer-declared
117
+ tools); `annotations` is `undefined` when the server omitted the field (per the MCP spec).
118
+
119
+ ```ts
120
+ for await (const event of result.eventStream) {
121
+ if (event.type === 'tool-approval-request') {
122
+ if (event.annotations?.readOnlyHint) {
123
+ // Auto-approve safe reads.
124
+ await session.approveToolCall(event.toolCall.toolCallId);
125
+ } else {
126
+ // Group/label by server when present, e.g. "sfdx wants to: deploy_metadata".
127
+ const approved = await promptUser(event.serverName, event.toolCall);
128
+ approved
129
+ ? await session.approveToolCall(event.toolCall.toolCallId)
130
+ : await session.declineToolCall(event.toolCall.toolCallId);
131
+ }
132
+ }
133
+ }
134
+ ```
135
+
112
136
  ### Using LLM Gateway Express
113
137
 
114
138
  ```ts
@@ -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,6 +169,63 @@ 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
+ }
180
+ /**
181
+ * Resolve the MCP server name and (optionally) annotations for a Mastra
182
+ * namespaced tool name (`${serverName}_${toolName}`). Returns `undefined` for
183
+ * tools that are not in any MCP server's catalog (consumer-declared client
184
+ * tools, workspace tools).
185
+ *
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.
199
+ *
200
+ * Design debt: this walk runs per `tool-call` / `tool-result` /
201
+ * `tool-approval-request` event and reconstructs the tool→server linkage from
202
+ * the namespaced string. The cost is O(servers) on a typically-small map, but
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.
212
+ */
213
+ function lookupMcpToolMetadata(agentState, namespacedToolName) {
214
+ let best;
215
+ for (const [serverName, serverState] of agentState.mcpServers) {
216
+ if (!namespacedToolName.startsWith(`${serverName}_`))
217
+ continue;
218
+ if (!(namespacedToolName in serverState.tools))
219
+ continue;
220
+ if (!best || serverName.length > best.serverName.length) {
221
+ best = { serverName, serverState };
222
+ }
223
+ }
224
+ if (!best)
225
+ return undefined;
226
+ const annotations = best.serverState.tools[namespacedToolName]?.mcp?.annotations;
227
+ return { serverName: best.serverName, ...(annotations ? { annotations } : {}) };
228
+ }
171
229
  /**
172
230
  * Projects the namespaced Mastra `ToolAction` map for one MCP server into the
173
231
  * SDK's harness-agnostic `McpToolInfo[]` shape.
@@ -361,78 +419,18 @@ export class MastraHarness {
361
419
  // Fallback only — per-server MCPServerConfig.timeout (in serverDef) takes precedence.
362
420
  timeout: DEFAULT_MCP_TIMEOUT_MS,
363
421
  });
364
- const startTime = this.clock.now().getTime();
365
- this.buses.emitTelemetry({
366
- type: 'mcp-server-discovery-started',
367
- timestamp: this.clock.now(),
368
- agentId,
369
- serverName,
370
- });
371
- const ready = makeAbortable(client.listToolsetsWithErrors(), options?.abortSignal)
372
- .then(({ toolsets, errors }) => {
373
- const serverError = errors[serverName];
374
- if (serverError) {
375
- throw new Error(serverError);
376
- }
377
- const tools = toolsets[serverName] ?? {};
378
- const namespacedTools = {};
379
- for (const [toolName, toolConfig] of Object.entries(tools)) {
380
- namespacedTools[`${serverName}_${toolName}`] = toolConfig;
381
- }
382
- mcpServerStates.get(serverName).tools = namespacedTools;
383
- // listToolsetsWithErrors() runs in the background and can resolve
384
- // after `shutdown()` has disposed the buses. Swallow only `DISPOSED`
385
- // so a real listener exception still surfaces.
386
- try {
387
- this.buses.emitTelemetry({
388
- type: 'mcp-server-discovery-completed',
389
- timestamp: this.clock.now(),
390
- agentId,
391
- serverName,
392
- toolCount: Object.keys(namespacedTools).length,
393
- durationMs: this.clock.now().getTime() - startTime,
394
- });
395
- }
396
- catch (emitErr) {
397
- if (!isDisposedError(emitErr))
398
- throw emitErr;
399
- }
400
- })
401
- .catch(async (err) => {
402
- if (options?.abortSignal?.aborted) {
403
- await client.disconnect().catch(() => undefined);
404
- mcpServerStates.delete(serverName);
405
- }
406
- else {
407
- const s = mcpServerStates.get(serverName);
408
- if (s)
409
- s.error = err instanceof Error ? err.message : String(err);
410
- this.buses.getLogBus()?.warn('MCP server discovery failed', {
411
- serverName,
412
- error: err instanceof Error ? err.message : String(err),
413
- });
414
- try {
415
- this.buses.emitTelemetry({
416
- type: 'mcp-server-discovery-failed',
417
- timestamp: this.clock.now(),
418
- agentId,
419
- serverName,
420
- durationMs: this.clock.now().getTime() - startTime,
421
- error: err instanceof Error ? err : new Error(String(err)),
422
- });
423
- }
424
- catch (emitErr) {
425
- if (!isDisposedError(emitErr))
426
- throw emitErr;
427
- }
428
- }
429
- })
430
- .finally(() => {
431
- const s = mcpServerStates.get(serverName);
432
- if (s)
433
- s.settled = true;
434
- });
435
- 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);
436
434
  }
437
435
  }
438
436
  const customTools = config.tools?.length ? mapToolDefinitionsToMastraTools(config.tools) : {};
@@ -452,6 +450,13 @@ export class MastraHarness {
452
450
  const localFs = new LocalFilesystem({ basePath: projectRoot, allowedPaths });
453
451
  const sandbox = new LocalSandbox({
454
452
  workingDirectory: projectRoot,
453
+ // Forward the parent process env so spawned commands see HOME,
454
+ // locale, etc. (LocalSandbox's default forwards only PATH, which
455
+ // breaks tools like `sf org list` that resolve config from HOME).
456
+ // TODO: filter sensitive vars (e.g. ANTHROPIC_*, AWS creds) here
457
+ // if the harness ever runs alongside secrets it shouldn't leak
458
+ // into spawned tool subprocesses.
459
+ env: process.env,
455
460
  });
456
461
  mastraWorkspace = new Workspace({
457
462
  filesystem: localFs,
@@ -594,6 +599,87 @@ export class MastraHarness {
594
599
  }
595
600
  return results;
596
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
+ }
597
683
  // ── Thread (Chat Session) Lifecycle ──────────────────────────────
598
684
  /**
599
685
  * @requirements
@@ -673,7 +759,12 @@ export class MastraHarness {
673
759
  this.getAgentStateOrThrow(agentId);
674
760
  const messages = await this.getMessages(agentId, threadId);
675
761
  if (messages.length === 0) {
676
- 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;
677
768
  }
678
769
  const transcript = messages
679
770
  .map((m) => {
@@ -684,7 +775,7 @@ export class MastraHarness {
684
775
  let newThreadId;
685
776
  try {
686
777
  const summaryText = transcript.length <= MAX_TRANSCRIPT_CHARS
687
- ? await this.summarize(agentId, this.buildSummaryPrompt(transcript))
778
+ ? await this.summarize(agentId, buildSummaryPrompt(transcript))
688
779
  : await this.summarizeInChunks(agentId, transcript);
689
780
  newThreadId = await this.createThread(agentId);
690
781
  const userTs = this.clock.now();
@@ -710,9 +801,26 @@ export class MastraHarness {
710
801
  // Source-thread cleanup is best-effort. The user's data is already
711
802
  // safely seeded into newThreadId; a failed delete leaves an orphan
712
803
  // but does not invalidate the compaction outcome.
713
- await this.destroyThread(agentId, threadId).catch(() => undefined);
804
+ await this.destroyThreadBestEffort(agentId, threadId);
714
805
  return newThreadId;
715
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
+ }
716
824
  // ── Core Messaging ───────────────────────────────────────────────
717
825
  /**
718
826
  * @requirements
@@ -730,6 +838,7 @@ export class MastraHarness {
730
838
  thread: threadId,
731
839
  resource: this.agentResourceId(agentId),
732
840
  },
841
+ requestContext: this.buildRequestContext(agentId, threadId),
733
842
  maxSteps: options?.maxSteps ?? DEFAULT_MAX_STEPS,
734
843
  abortSignal: options?.abortSignal,
735
844
  requireToolApproval: options?.requireToolApproval,
@@ -767,6 +876,7 @@ export class MastraHarness {
767
876
  thread: threadId,
768
877
  resource: this.agentResourceId(agentId),
769
878
  },
879
+ requestContext: this.buildRequestContext(agentId, threadId),
770
880
  });
771
881
  return this.toChatStreamResult(agentState, threadId, output);
772
882
  }
@@ -804,6 +914,7 @@ export class MastraHarness {
804
914
  thread: threadId,
805
915
  resource: this.agentResourceId(agentId),
806
916
  },
917
+ requestContext: this.buildRequestContext(agentId, threadId),
807
918
  });
808
919
  return this.toChatStreamResult(agentState, threadId, output);
809
920
  }
@@ -835,6 +946,7 @@ export class MastraHarness {
835
946
  thread: threadId,
836
947
  resource: this.agentResourceId(agentId),
837
948
  },
949
+ requestContext: this.buildRequestContext(agentId, threadId),
838
950
  });
839
951
  return this.toChatStreamResult(agentState, threadId, output);
840
952
  }
@@ -898,15 +1010,14 @@ export class MastraHarness {
898
1010
  */
899
1011
  async summarize(agentId, prompt) {
900
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.
901
1018
  const result = await agentState.agent.generate(prompt, { maxSteps: 1 });
902
1019
  return result.text;
903
1020
  }
904
- buildSummaryPrompt(transcript) {
905
- return ('Summarize the following conversation into a concise context summary. ' +
906
- 'Preserve key decisions, facts, and action items. ' +
907
- 'Write in third person as a factual record.\n\n' +
908
- transcript);
909
- }
910
1021
  /**
911
1022
  * Two-pass summarization for transcripts above the char budget. The
912
1023
  * transcript is split into roughly-equal chunks each ≤ MAX_TRANSCRIPT_CHARS,
@@ -917,7 +1028,7 @@ export class MastraHarness {
917
1028
  const chunks = this.splitIntoChunks(transcript, MAX_TRANSCRIPT_CHARS);
918
1029
  const chunkSummaries = [];
919
1030
  for (const chunk of chunks) {
920
- const summary = await this.summarize(agentId, this.buildSummaryPrompt(chunk));
1031
+ const summary = await this.summarize(agentId, buildSummaryPrompt(chunk));
921
1032
  chunkSummaries.push(summary);
922
1033
  }
923
1034
  const merged = chunkSummaries.map((s, i) => `Part ${i + 1}:\n${s}`).join('\n\n');
@@ -925,7 +1036,7 @@ export class MastraHarness {
925
1036
  // long chat could in principle still exceed budget. If so, recurse;
926
1037
  // otherwise final-summarize.
927
1038
  return merged.length <= MAX_TRANSCRIPT_CHARS
928
- ? this.summarize(agentId, this.buildSummaryPrompt(merged))
1039
+ ? this.summarize(agentId, buildSummaryPrompt(merged))
929
1040
  : this.summarizeInChunks(agentId, merged);
930
1041
  }
931
1042
  /**
@@ -969,6 +1080,117 @@ export class MastraHarness {
969
1080
  }
970
1081
  return state;
971
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
+ }
972
1194
  toChatStreamResult(agentState, threadId, output) {
973
1195
  // Entries are keyed by threadId so each thread holds at most one entry.
974
1196
  // Stale entries are overwritten here on the next stream; explicit cleanup
@@ -978,7 +1200,7 @@ export class MastraHarness {
978
1200
  agentState.activeRuns.set(threadId, { runId: output.runId, fullStream: output.fullStream });
979
1201
  const adapter = new MastraEventAdapter(this.buses.getLogBus());
980
1202
  return {
981
- eventStream: this.createEventStream(output.fullStream, adapter),
1203
+ eventStream: this.createEventStream(output.fullStream, adapter, agentState),
982
1204
  textStream: this.readableStreamToAsyncGenerator(output.textStream),
983
1205
  };
984
1206
  }
@@ -993,11 +1215,41 @@ export class MastraHarness {
993
1215
  agentResourceId(agentId) {
994
1216
  return `AGENT_INSTANCE:${agentId}`;
995
1217
  }
996
- async *createEventStream(fullStream, adapter) {
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
+ }
1236
+ async *createEventStream(fullStream, adapter, agentState) {
997
1237
  for await (const chunk of this.readableStreamToAsyncGenerator(fullStream)) {
998
1238
  const event = adapter.toChatEvent(chunk);
999
1239
  if (!event)
1000
1240
  continue;
1241
+ if (isEnrichableToolEvent(event)) {
1242
+ const toolName = event.type === 'tool-approval-request' ? event.toolCall.toolName : event.toolName;
1243
+ const metadata = lookupMcpToolMetadata(agentState, toolName);
1244
+ if (metadata) {
1245
+ yield {
1246
+ ...event,
1247
+ serverName: metadata.serverName,
1248
+ ...(metadata.annotations ? { annotations: metadata.annotations } : {}),
1249
+ };
1250
+ continue;
1251
+ }
1252
+ }
1001
1253
  yield event;
1002
1254
  }
1003
1255
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/sfdx-agent-harness-mastra",
3
- "version": "0.6.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.4.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.6.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.6.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",