@salesforce/sfdx-agent-harness-mastra 0.38.0 → 0.40.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/CHANGELOG.md CHANGED
@@ -3,6 +3,16 @@
3
3
  All notable changes to `@salesforce/sfdx-agent-harness-mastra` are documented in this file.
4
4
  Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
+ ## [0.40.0] - 2026-08-03
7
+
8
+ ### Features
9
+ - **agent-sdk,harnesses**: emit Tier-1 logBus siblings for lifecycle telemetry @W-23547150@ ([#716](https://github.com/forcedotcom/agentic-dx/pull/716))
10
+
11
+ ## [0.39.0] - 2026-08-03
12
+
13
+ ### Features
14
+ - **agent-sdk,harness-mastra,harness-claude,harness-openai**: observability gaps — mcp-tool-call-completed, llm-retry, responseText/TTFT @W-23528008@ ([#717](https://github.com/forcedotcom/agentic-dx/pull/717))
15
+
6
16
  ## [0.38.0] - 2026-07-30
7
17
 
8
18
  ### Features
package/README.md CHANGED
@@ -24,6 +24,16 @@ const manager = await createAgentManager('/path/to/storage', new MastraHarnessFa
24
24
  See the [`@salesforce/sfdx-agent-sdk` README](../sfdx-agent-sdk/README.md) for the full consumer-facing API. For
25
25
  internal architecture, see [ARCHITECTURE.md](ARCHITECTURE.md).
26
26
 
27
+ ### Observability compatibility
28
+
29
+ This in-process harness emits structured `llm-request` / `llm-response` wire events. Matching events carry the
30
+ harness-generated `correlationId`; `llm-response` provides best-effort `responseText` and `timeToFirstTokenMs` after the
31
+ streaming body terminates. It also emits the debug-only `mcp-tool-call-completed` event; its `durationMs` is an
32
+ adapter/coordinator-observed window and can include a model round-trip. It does **not** emit `llm-retry` telemetry
33
+ because its stateless fetch layer has no observable retry attempt counter. See the SDK README's
34
+ [cross-harness coverage table](../sfdx-agent-sdk/README.md#cross-harness-coverage) for the complete contract and
35
+ cross-harness comparison.
36
+
27
37
  ### Removing the storage directory on Windows
28
38
 
29
39
  `manager.shutdown()` checkpoints the WAL and closes the libsql client (forcedotcom/agentic-dx#429,
@@ -1,9 +1,9 @@
1
1
  import type { Agent } from '@mastra/core/agent';
2
2
  import type { RequestContext } from '@mastra/core/request-context';
3
3
  import type { ChunkType } from '@mastra/core/stream';
4
- import { type ChatEvent, type Decision as PolicyDecision, type McpToolAnnotations, type ToolInvocation } from '@salesforce/sfdx-agent-sdk';
4
+ import { type ChatEvent, type Decision as PolicyDecision, type McpToolAnnotations, type ToolInvocation, type WireCommunicationEvent } from '@salesforce/sfdx-agent-sdk';
5
5
  import { GenSink } from '@salesforce/sfdx-agent-sdk/harness';
6
- import type { LogBus } from '@salesforce/agentic-common';
6
+ import type { Clock, LogBus } from '@salesforce/agentic-common';
7
7
  /**
8
8
  * Output shape from `agent.stream()` / `agent.approveToolCall()` /
9
9
  * `agent.declineToolCall()`. Captured as a structural type so the
@@ -113,6 +113,9 @@ export interface MastraApprovalCoordinatorOptions {
113
113
  */
114
114
  skillBridgeToolNames?: ReadonlySet<string>;
115
115
  logBus?: LogBus;
116
+ clock: Clock;
117
+ emitWireCommunication: (event: WireCommunicationEvent) => void;
118
+ hasWireCommunicationSubscribers: () => boolean;
116
119
  }
117
120
  /**
118
121
  * Per-`(agentId, threadId)` coordinator that subsumes the pre-#529
@@ -161,6 +164,9 @@ export declare class MastraApprovalCoordinator {
161
164
  private readonly consumerToolNames;
162
165
  private readonly lookupMcpToolMetadata?;
163
166
  private readonly skillBridgeToolNames;
167
+ private readonly clock;
168
+ private readonly emitWireCommunication;
169
+ private readonly hasWireCommunicationSubscribers;
164
170
  private readonly adapter;
165
171
  /** Single per-turn sink. Issue #529 contract: one stream per turn. */
166
172
  private readonly initial;
@@ -183,6 +189,7 @@ export declare class MastraApprovalCoordinator {
183
189
  private readonly approvalTimers;
184
190
  /** Args from the adapter's `tool-call` ChatEvent keyed by `toolCallId`. */
185
191
  private readonly toolCallArgs;
192
+ private readonly pendingMcpToolCallStarts;
186
193
  /**
187
194
  * Mastra's current `runId` after the latest stream/resume. Updated on
188
195
  * each successful pump entry. Needed for the next
@@ -395,6 +402,12 @@ export declare class MastraApprovalCoordinator {
395
402
  * `tool-result` / `tool-approval-request`.
396
403
  */
397
404
  private routeEnriched;
405
+ /**
406
+ * Emits a coordinator-observed call-to-result window for a real MCP tool.
407
+ * Mastra durationMs includes model round-trip time, so it is not comparable
408
+ * to the Claude bridge's host-to-MCP RPC latency.
409
+ */
410
+ private emitMcpToolCallCompleted;
398
411
  private maybeEnrich;
399
412
  /**
400
413
  * Build the {@link ToolInvocation} the policy resolver matches against, and
@@ -76,6 +76,9 @@ export class MastraApprovalCoordinator {
76
76
  consumerToolNames;
77
77
  lookupMcpToolMetadata;
78
78
  skillBridgeToolNames;
79
+ clock;
80
+ emitWireCommunication;
81
+ hasWireCommunicationSubscribers;
79
82
  adapter;
80
83
  /** Single per-turn sink. Issue #529 contract: one stream per turn. */
81
84
  initial = new GenSink();
@@ -98,6 +101,7 @@ export class MastraApprovalCoordinator {
98
101
  approvalTimers = new Map();
99
102
  /** Args from the adapter's `tool-call` ChatEvent keyed by `toolCallId`. */
100
103
  toolCallArgs = new Map();
104
+ pendingMcpToolCallStarts = new Map();
101
105
  /**
102
106
  * Mastra's current `runId` after the latest stream/resume. Updated on
103
107
  * each successful pump entry. Needed for the next
@@ -177,6 +181,9 @@ export class MastraApprovalCoordinator {
177
181
  this.consumerToolNames = opts.consumerToolNames;
178
182
  this.lookupMcpToolMetadata = opts.lookupMcpToolMetadata;
179
183
  this.skillBridgeToolNames = opts.skillBridgeToolNames ?? new Set();
184
+ this.clock = opts.clock;
185
+ this.emitWireCommunication = opts.emitWireCommunication;
186
+ this.hasWireCommunicationSubscribers = opts.hasWireCommunicationSubscribers;
180
187
  this.adapter = new MastraEventAdapter(opts.logBus);
181
188
  }
182
189
  // ───────────────────────── lifecycle / sinks ────────────────────────────
@@ -302,6 +309,7 @@ export class MastraApprovalCoordinator {
302
309
  */
303
310
  teardown(reason, error, code) {
304
311
  this.cancelAllTimers();
312
+ this.pendingMcpToolCallStarts.clear();
305
313
  for (const id of this.emittedApprovals)
306
314
  this.settledByCoordinator.add(id);
307
315
  // Issue #589: drain in-flight consumer-tool ids into the
@@ -546,6 +554,7 @@ export class MastraApprovalCoordinator {
546
554
  sawTerminal = true;
547
555
  this.cancelAllTimers();
548
556
  this.routeEnriched(event);
557
+ this.pendingMcpToolCallStarts.clear();
549
558
  this.initial.end();
550
559
  return 'terminal';
551
560
  }
@@ -553,6 +562,7 @@ export class MastraApprovalCoordinator {
553
562
  sawTerminal = true;
554
563
  this.cancelAllTimers();
555
564
  this.routeEnriched(event);
565
+ this.pendingMcpToolCallStarts.clear();
556
566
  // Per SDK contract: 'error' is terminal; emit a synthetic
557
567
  // 'finish' so wrapEventStream's invariant holds.
558
568
  this.initial.push({ type: 'finish', finishReason: 'error' });
@@ -676,6 +686,7 @@ export class MastraApprovalCoordinator {
676
686
  return 'suspended';
677
687
  }
678
688
  if (!sawTerminal && !this.initial.isEnded()) {
689
+ this.pendingMcpToolCallStarts.clear();
679
690
  this.initial.end();
680
691
  }
681
692
  return 'terminal';
@@ -753,14 +764,54 @@ export class MastraApprovalCoordinator {
753
764
  if (enriched.type === 'tool-call' && isRealMcpServer(enriched.serverName)) {
754
765
  this.currentMcpToolCallId = enriched.toolCallId;
755
766
  this.toolNameForCallId.set(enriched.toolCallId, enriched.toolName);
767
+ if (this.hasWireCommunicationSubscribers()) {
768
+ this.pendingMcpToolCallStarts.set(enriched.toolCallId, this.clock.now());
769
+ }
756
770
  }
757
771
  else if (enriched.type === 'tool-result' &&
758
772
  isRealMcpServer(enriched.serverName) &&
759
773
  this.currentMcpToolCallId === enriched.toolCallId) {
760
774
  this.currentMcpToolCallId = null;
761
775
  }
776
+ if (enriched.type === 'tool-result' && isRealMcpServer(enriched.serverName)) {
777
+ this.emitMcpToolCallCompleted(enriched);
778
+ }
762
779
  this.initial.push(enriched);
763
780
  }
781
+ /**
782
+ * Emits a coordinator-observed call-to-result window for a real MCP tool.
783
+ * Mastra durationMs includes model round-trip time, so it is not comparable
784
+ * to the Claude bridge's host-to-MCP RPC latency.
785
+ */
786
+ emitMcpToolCallCompleted(event) {
787
+ const startedAt = this.pendingMcpToolCallStarts.get(event.toolCallId);
788
+ if (startedAt === undefined)
789
+ return;
790
+ this.pendingMcpToolCallStarts.delete(event.toolCallId);
791
+ if (!this.hasWireCommunicationSubscribers())
792
+ return;
793
+ const completedAt = this.clock.now();
794
+ const serverName = event.serverName;
795
+ if (serverName === undefined)
796
+ return;
797
+ try {
798
+ this.emitWireCommunication({
799
+ type: 'mcp-tool-call-completed',
800
+ timestamp: completedAt,
801
+ serverName,
802
+ toolName: event.toolName,
803
+ bareToolName: event.bareToolName ?? event.toolName,
804
+ args: this.toolCallArgs.get(event.toolCallId) ?? {},
805
+ result: event.result,
806
+ durationMs: completedAt.getTime() - startedAt.getTime(),
807
+ });
808
+ }
809
+ catch (err) {
810
+ if (err instanceof AgentSDKError && err.type === AgentSDKErrorType.DISPOSED)
811
+ return;
812
+ throw err;
813
+ }
814
+ }
764
815
  maybeEnrich(event) {
765
816
  if (event.type !== 'tool-call' && event.type !== 'tool-result' && event.type !== 'tool-approval-request') {
766
817
  return event;
@@ -266,6 +266,7 @@ export declare class MastraHarness implements MastraAgentHarness {
266
266
  private readonly idGenerator;
267
267
  private readonly toolApprovalTimeoutMs;
268
268
  private readonly innerFetch;
269
+ private readonly wireObserverAbortController;
269
270
  private readonly languageModelBuilders?;
270
271
  constructor(storageRootFolder: string, opts?: MastraHarnessOptions);
271
272
  /**
@@ -398,6 +398,7 @@ export class MastraHarness {
398
398
  idGenerator;
399
399
  toolApprovalTimeoutMs;
400
400
  innerFetch;
401
+ wireObserverAbortController = new AbortController();
401
402
  languageModelBuilders;
402
403
  constructor(storageRootFolder, opts = {}) {
403
404
  this.storageRootFolder = storageRootFolder;
@@ -436,6 +437,7 @@ export class MastraHarness {
436
437
  * (forcedotcom/agentic-dx#429, mastra-ai/mastra#17285).
437
438
  */
438
439
  async shutdown() {
440
+ this.wireObserverAbortController.abort();
439
441
  await Promise.all([...this.activeAgents.values()].map(async (state) => {
440
442
  for (const coordinator of state.coordinators.values()) {
441
443
  coordinator.dispose();
@@ -849,21 +851,31 @@ export class MastraHarness {
849
851
  serverState.errorDetail = errorDetail;
850
852
  serverState.settled = true;
851
853
  const emitStatusChange = this.prepareMcpStatusChange(agentId, serverName, serverState, McpServerStatus.Error, message);
852
- this.buses.getLogBus()?.warn('MCP server reconnect failed', {
853
- agentId,
854
- serverName,
855
- error: message,
856
- });
857
854
  try {
855
+ const failedAt = this.clock.now();
856
+ const failedError = effectiveError instanceof Error ? effectiveError : new Error(message);
858
857
  this.buses.emitTelemetry({
859
858
  type: 'mcp-server-discovery-failed',
860
- timestamp: this.clock.now(),
859
+ timestamp: failedAt,
861
860
  agentId,
862
861
  serverName,
863
- durationMs: this.clock.now().getTime() - startTime,
864
- error: effectiveError instanceof Error ? effectiveError : new Error(message),
862
+ durationMs: failedAt.getTime() - startTime,
863
+ error: failedError,
865
864
  errorDetail,
866
865
  });
866
+ this.buses.emitLog({
867
+ level: 'error',
868
+ message: 'MCP server discovery failed',
869
+ timestamp: failedAt,
870
+ context: {
871
+ event_type: 'mcp-server-discovery-failed',
872
+ agentId,
873
+ serverName,
874
+ durationMs: failedAt.getTime() - startTime,
875
+ errorDetail,
876
+ },
877
+ error: failedError,
878
+ });
867
879
  }
868
880
  catch (emitErr) {
869
881
  if (!isDisposedError(emitErr))
@@ -1290,25 +1302,34 @@ export class MastraHarness {
1290
1302
  }
1291
1303
  };
1292
1304
  const innerFetch = this.innerFetch;
1305
+ const wireHooks = {
1306
+ emitWireCommunication,
1307
+ getModel: () => getInfo().nativeModelId,
1308
+ getProviderHint: () => getInfo().providerHint,
1309
+ clock: this.clock,
1310
+ idGenerator: this.idGenerator,
1311
+ hasWireCommunicationSubscribers: () => this.buses.hasWireCommunicationSubscribers(),
1312
+ disposeSignal: this.wireObserverAbortController.signal,
1313
+ };
1293
1314
  switch (initial.providerHint) {
1294
1315
  case 'bedrock-anthropic':
1295
1316
  // Salesforce LLM Gateway's `/invoke-with-response-stream` (Bedrock pass-through).
1296
- return buildAnthropicLanguageModel({ getInfo, logBus, emitWireCommunication, innerFetch });
1317
+ return buildAnthropicLanguageModel({ getInfo, logBus, wireHooks, innerFetch });
1297
1318
  case 'anthropic':
1298
1319
  // Direct Anthropic Messages API (`api.anthropic.com/v1/messages`) —
1299
1320
  // different wire shape (`x-api-key` auth, body without `anthropic_version`).
1300
- return buildAnthropicDirectLanguageModel({ getInfo, logBus, emitWireCommunication, innerFetch });
1321
+ return buildAnthropicDirectLanguageModel({ getInfo, logBus, wireHooks, innerFetch });
1301
1322
  case 'openai-responses':
1302
1323
  case 'openai':
1303
1324
  // OpenAI Responses API. Salesforce LLM Gateway's `/responses` pass-through
1304
1325
  // for `'openai-responses'`; `api.openai.com/v1/responses` for `'openai'` direct.
1305
1326
  // Same wire shape; the only difference is the `baseUrl` the resolver supplies.
1306
- return buildOpenAILanguageModel({ getInfo, logBus, emitWireCommunication, innerFetch });
1327
+ return buildOpenAILanguageModel({ getInfo, logBus, wireHooks, innerFetch });
1307
1328
  case 'openai-compatible':
1308
1329
  // OpenAI-compatible Chat Completions API (LLMG Express, LiteLLM, etc.).
1309
1330
  // Different wire shape from Responses — `/v1/chat/completions` with
1310
1331
  // `choices[0].delta` streaming events.
1311
- return buildOpenAIChatCompletionsLanguageModel({ getInfo, logBus, emitWireCommunication, innerFetch });
1332
+ return buildOpenAIChatCompletionsLanguageModel({ getInfo, logBus, wireHooks, innerFetch });
1312
1333
  default: {
1313
1334
  // Consult the injected builders map before throwing. This is the
1314
1335
  // extension point for consumer-registered wire shapes (e.g. a temporary
@@ -1486,6 +1507,9 @@ export class MastraHarness {
1486
1507
  lookupMcpToolMetadata: (name) => lookupMcpToolMetadata(agentState.mcpServers, name),
1487
1508
  skillBridgeToolNames: activeSkillBridgeToolNames(agentState),
1488
1509
  logBus: this.buses.getLogBus(),
1510
+ clock: this.clock,
1511
+ emitWireCommunication: (event) => this.buses.emitWireCommunication(event),
1512
+ hasWireCommunicationSubscribers: () => this.buses.hasWireCommunicationSubscribers(),
1489
1513
  });
1490
1514
  agentState.coordinators.set(threadId, coordinator);
1491
1515
  const abortSignal = options?.abortSignal;
@@ -1754,15 +1778,31 @@ export class MastraHarness {
1754
1778
  serverState.currentStatus = nextStatus;
1755
1779
  return () => {
1756
1780
  try {
1781
+ const changedAt = this.clock.now();
1757
1782
  this.buses.emitTelemetry({
1758
1783
  type: 'mcp-server-status-changed',
1759
- timestamp: this.clock.now(),
1784
+ timestamp: changedAt,
1760
1785
  agentId,
1761
1786
  serverName,
1762
1787
  previousStatus,
1763
1788
  nextStatus,
1764
1789
  ...(error !== undefined ? { error } : {}),
1765
1790
  });
1791
+ // Consider dropping this if it becomes too noisy.
1792
+ const isErrorStatus = nextStatus === McpServerStatus.Error;
1793
+ this.buses.emitLog({
1794
+ level: isErrorStatus ? 'error' : 'info',
1795
+ message: 'MCP server status changed',
1796
+ timestamp: changedAt,
1797
+ context: {
1798
+ event_type: 'mcp-server-status-changed',
1799
+ agentId,
1800
+ serverName,
1801
+ previousStatus,
1802
+ nextStatus,
1803
+ ...(error !== undefined ? { error } : {}),
1804
+ },
1805
+ });
1766
1806
  }
1767
1807
  catch (emitErr) {
1768
1808
  if (!isDisposedError(emitErr))
@@ -1891,13 +1931,29 @@ export class MastraHarness {
1891
1931
  // after `shutdown()` has disposed the buses. Swallow only `DISPOSED`
1892
1932
  // so a real listener exception still surfaces.
1893
1933
  try {
1934
+ const completedAt = this.clock.now();
1935
+ const toolCount = Object.keys(namespacedTools).length;
1936
+ const durationMs = completedAt.getTime() - startTime;
1894
1937
  this.buses.emitTelemetry({
1895
1938
  type: 'mcp-server-discovery-completed',
1896
- timestamp: this.clock.now(),
1939
+ timestamp: completedAt,
1897
1940
  agentId,
1898
1941
  serverName,
1899
- toolCount: Object.keys(namespacedTools).length,
1900
- durationMs: this.clock.now().getTime() - startTime,
1942
+ toolCount,
1943
+ durationMs,
1944
+ });
1945
+ // Consider dropping this if it becomes too noisy.
1946
+ this.buses.emitLog({
1947
+ level: 'info',
1948
+ message: 'MCP server discovery completed',
1949
+ timestamp: completedAt,
1950
+ context: {
1951
+ event_type: 'mcp-server-discovery-completed',
1952
+ agentId,
1953
+ serverName,
1954
+ toolCount,
1955
+ durationMs,
1956
+ },
1901
1957
  });
1902
1958
  }
1903
1959
  catch (emitErr) {
@@ -1937,21 +1993,32 @@ export class MastraHarness {
1937
1993
  s.errorDetail = errorDetail;
1938
1994
  s.settled = true;
1939
1995
  const emitStatusChange = this.prepareMcpStatusChange(agentId, serverName, s, McpServerStatus.Error, errorMessage);
1940
- this.buses.getLogBus()?.warn('MCP server discovery failed', {
1941
- agentId,
1942
- serverName,
1943
- error: errorMessage,
1944
- });
1945
1996
  try {
1997
+ const failedAt = this.clock.now();
1998
+ const failedError = effectiveError instanceof Error ? effectiveError : new Error(String(effectiveError));
1999
+ const durationMs = failedAt.getTime() - startTime;
1946
2000
  this.buses.emitTelemetry({
1947
2001
  type: 'mcp-server-discovery-failed',
1948
- timestamp: this.clock.now(),
2002
+ timestamp: failedAt,
1949
2003
  agentId,
1950
2004
  serverName,
1951
- durationMs: this.clock.now().getTime() - startTime,
1952
- error: effectiveError instanceof Error ? effectiveError : new Error(String(effectiveError)),
2005
+ durationMs,
2006
+ error: failedError,
1953
2007
  errorDetail,
1954
2008
  });
2009
+ this.buses.emitLog({
2010
+ level: 'error',
2011
+ message: 'MCP server discovery failed',
2012
+ timestamp: failedAt,
2013
+ context: {
2014
+ event_type: 'mcp-server-discovery-failed',
2015
+ agentId,
2016
+ serverName,
2017
+ durationMs,
2018
+ errorDetail,
2019
+ },
2020
+ error: failedError,
2021
+ });
1955
2022
  }
1956
2023
  catch (emitErr) {
1957
2024
  if (!isDisposedError(emitErr))
@@ -2,6 +2,7 @@ import Anthropic from '@anthropic-ai/sdk';
2
2
  import type { LanguageModelV3, LanguageModelV3CallOptions, LanguageModelV3Prompt } from '@ai-sdk/provider';
3
3
  import type { LogBus } from '@salesforce/agentic-common';
4
4
  import type { ModelConnectivityInfo, WireCommunicationEvent } from '@salesforce/sfdx-agent-sdk';
5
+ import { type GatewayFetchWireHooks } from './v3-stream-mapping.js';
5
6
  /**
6
7
  * Builds the in-process `LanguageModelV3` impl for the Salesforce LLM Gateway's
7
8
  * `/invoke-with-response-stream` (Bedrock-Anthropic pass-through) endpoint.
@@ -20,6 +21,7 @@ export declare function buildAnthropicLanguageModel(opts: {
20
21
  getInfo: () => ModelConnectivityInfo;
21
22
  logBus?: LogBus;
22
23
  emitWireCommunication?: (event: WireCommunicationEvent) => void;
24
+ wireHooks?: GatewayFetchWireHooks;
23
25
  /**
24
26
  * Inner fetch routed through the harness's `Dispatcher` (built from
25
27
  * `HTTPS_PROXY` / `HTTP_PROXY` or a consumer-supplied one). Defaults to
@@ -51,6 +53,7 @@ export declare function buildAnthropicDirectLanguageModel(opts: {
51
53
  getInfo: () => ModelConnectivityInfo;
52
54
  logBus?: LogBus;
53
55
  emitWireCommunication?: (event: WireCommunicationEvent) => void;
56
+ wireHooks?: GatewayFetchWireHooks;
54
57
  /** See {@link buildAnthropicLanguageModel} `innerFetch`. */
55
58
  innerFetch?: typeof fetch;
56
59
  }): LanguageModelV3;
@@ -20,19 +20,20 @@ import { TextSpanLifecycle, createGatewayFetch, isEmptyUsage, isParameterExclude
20
20
  * the next request.
21
21
  */
22
22
  export function buildAnthropicLanguageModel(opts) {
23
- const { getInfo, logBus, emitWireCommunication, innerFetch } = opts;
23
+ const { getInfo, logBus, emitWireCommunication, wireHooks, innerFetch } = opts;
24
24
  const initialInfo = getInfo();
25
25
  const ctx = {
26
26
  getInfo,
27
27
  logBus,
28
28
  emitWireCommunication,
29
+ wireHooks,
29
30
  buildClient: (info) => new AnthropicBedrock({
30
31
  baseURL: info.baseUrl,
31
32
  skipAuth: true,
32
33
  // The fetch wrapper re-evaluates `getHeaders()` on every outbound call —
33
34
  // JWT rotation, premium-rate-limit gate flips, BYOK provider rotation
34
35
  // all land on the next request without rebuilding the bridge.
35
- fetch: createGatewayFetch(() => info.getHeaders(), buildWireHooks(ctx), innerFetch),
36
+ fetch: createGatewayFetch(() => info.getHeaders(), ctx.wireHooks ?? buildWireHooks(ctx), innerFetch),
36
37
  }),
37
38
  };
38
39
  return {
@@ -68,12 +69,13 @@ export function buildAnthropicLanguageModel(opts) {
68
69
  * language model.
69
70
  */
70
71
  export function buildAnthropicDirectLanguageModel(opts) {
71
- const { getInfo, logBus, emitWireCommunication, innerFetch } = opts;
72
+ const { getInfo, logBus, emitWireCommunication, wireHooks, innerFetch } = opts;
72
73
  const initialInfo = getInfo();
73
74
  const ctx = {
74
75
  getInfo,
75
76
  logBus,
76
77
  emitWireCommunication,
78
+ wireHooks,
77
79
  buildClient: (info) =>
78
80
  // Anthropic SDK constructor takes `apiKey` and writes `x-api-key` itself.
79
81
  // We use a non-empty placeholder here because the SDK requires `apiKey`
@@ -85,7 +87,7 @@ export function buildAnthropicDirectLanguageModel(opts) {
85
87
  new Anthropic({
86
88
  apiKey: 'anthropic-direct-resolver-managed',
87
89
  baseURL: info.baseUrl,
88
- fetch: createGatewayFetch(() => translateBearerToXApiKey(info), buildWireHooks(ctx), innerFetch),
90
+ fetch: createGatewayFetch(() => translateBearerToXApiKey(info), ctx.wireHooks ?? buildWireHooks(ctx), innerFetch),
89
91
  }),
90
92
  };
91
93
  return {
@@ -2,6 +2,7 @@ import type { ChatCompletionMessageParam, ChatCompletionTool } from 'openai/reso
2
2
  import type { LanguageModelV3, LanguageModelV3CallOptions, LanguageModelV3FinishReason, LanguageModelV3Prompt } from '@ai-sdk/provider';
3
3
  import type { LogBus } from '@salesforce/agentic-common';
4
4
  import type { ModelConnectivityInfo, WireCommunicationEvent } from '@salesforce/sfdx-agent-sdk';
5
+ import { type GatewayFetchWireHooks } from './v3-stream-mapping.js';
5
6
  /**
6
7
  * Builds the in-process `LanguageModelV3` impl that drives the `openai` SDK's
7
8
  * `chat.completions.create({ stream: true })` against an OpenAI-compatible
@@ -26,6 +27,7 @@ export declare function buildOpenAIChatCompletionsLanguageModel(opts: {
26
27
  getInfo: () => ModelConnectivityInfo;
27
28
  logBus?: LogBus;
28
29
  emitWireCommunication?: (event: WireCommunicationEvent) => void;
30
+ wireHooks?: GatewayFetchWireHooks;
29
31
  /** See `buildAnthropicLanguageModel` `innerFetch`. */
30
32
  innerFetch?: typeof fetch;
31
33
  }): LanguageModelV3;
@@ -25,9 +25,9 @@ import { TextSpanLifecycle, createGatewayFetch, isEmptyUsage, isParameterExclude
25
25
  * without rebuilding the language model.
26
26
  */
27
27
  export function buildOpenAIChatCompletionsLanguageModel(opts) {
28
- const { getInfo, logBus, emitWireCommunication, innerFetch } = opts;
28
+ const { getInfo, logBus, emitWireCommunication, wireHooks, innerFetch } = opts;
29
29
  const initialInfo = getInfo();
30
- const ctx = { getInfo, logBus, emitWireCommunication, innerFetch };
30
+ const ctx = { getInfo, logBus, emitWireCommunication, wireHooks, innerFetch };
31
31
  return {
32
32
  specificationVersion: 'v3',
33
33
  provider: 'openai-compatible-chat-completions-pass-through',
@@ -45,9 +45,10 @@ export function buildOpenAIChatCompletionsLanguageModel(opts) {
45
45
  */
46
46
  async function buildRequest(ctx, options) {
47
47
  const info = ctx.getInfo();
48
- const wireHooks = ctx.emitWireCommunication
49
- ? { getModel: () => ctx.getInfo().nativeModelId, emitWireCommunication: ctx.emitWireCommunication }
50
- : undefined;
48
+ const wireHooks = ctx.wireHooks ??
49
+ (ctx.emitWireCommunication
50
+ ? { getModel: () => ctx.getInfo().nativeModelId, emitWireCommunication: ctx.emitWireCommunication }
51
+ : undefined);
51
52
  const client = new OpenAI({
52
53
  baseURL: info.baseUrl,
53
54
  // The custom fetch injects the resolver-built `Authorization` header per
@@ -2,6 +2,7 @@ import type { ResponseInput, Tool } from 'openai/resources/responses/responses';
2
2
  import type { LanguageModelV3, LanguageModelV3CallOptions, LanguageModelV3Prompt } from '@ai-sdk/provider';
3
3
  import type { LogBus } from '@salesforce/agentic-common';
4
4
  import type { ModelConnectivityInfo, WireCommunicationEvent } from '@salesforce/sfdx-agent-sdk';
5
+ import { type GatewayFetchWireHooks } from './v3-stream-mapping.js';
5
6
  /**
6
7
  * Builds the in-process `LanguageModelV3` impl that drives the `openai` SDK's
7
8
  * `responses.create({ stream: true })` against the Salesforce LLM Gateway's
@@ -18,6 +19,7 @@ export declare function buildOpenAILanguageModel(opts: {
18
19
  getInfo: () => ModelConnectivityInfo;
19
20
  logBus?: LogBus;
20
21
  emitWireCommunication?: (event: WireCommunicationEvent) => void;
22
+ wireHooks?: GatewayFetchWireHooks;
21
23
  /** See `buildAnthropicLanguageModel` `innerFetch`. */
22
24
  innerFetch?: typeof fetch;
23
25
  }): LanguageModelV3;
@@ -17,9 +17,9 @@ import { TextSpanLifecycle, createGatewayFetch, isEmptyUsage, isParameterExclude
17
17
  * next request.
18
18
  */
19
19
  export function buildOpenAILanguageModel(opts) {
20
- const { getInfo, logBus, emitWireCommunication, innerFetch } = opts;
20
+ const { getInfo, logBus, emitWireCommunication, wireHooks, innerFetch } = opts;
21
21
  const initialInfo = getInfo();
22
- const ctx = { getInfo, logBus, emitWireCommunication, innerFetch };
22
+ const ctx = { getInfo, logBus, emitWireCommunication, wireHooks, innerFetch };
23
23
  return {
24
24
  specificationVersion: 'v3',
25
25
  provider: 'salesforce-llm-gateway-pass-through-openai',
@@ -41,9 +41,10 @@ export function buildOpenAILanguageModel(opts) {
41
41
  */
42
42
  async function buildOpenAIRequest(ctx, options) {
43
43
  const info = ctx.getInfo();
44
- const wireHooks = ctx.emitWireCommunication
45
- ? { getModel: () => ctx.getInfo().nativeModelId, emitWireCommunication: ctx.emitWireCommunication }
46
- : undefined;
44
+ const wireHooks = ctx.wireHooks ??
45
+ (ctx.emitWireCommunication
46
+ ? { getModel: () => ctx.getInfo().nativeModelId, emitWireCommunication: ctx.emitWireCommunication }
47
+ : undefined);
47
48
  const client = new OpenAI({
48
49
  baseURL: info.baseUrl,
49
50
  // The custom fetch injects the resolver-built `Authorization` header per
@@ -1,5 +1,5 @@
1
1
  import type { LanguageModelV3FinishReason, LanguageModelV3StreamPart, LanguageModelV3ToolResultOutput, LanguageModelV3Usage } from '@ai-sdk/provider';
2
- import { Clock } from '@salesforce/agentic-common';
2
+ import { type Clock, type UniqueIDGenerator } from '@salesforce/agentic-common';
3
3
  import type { Model } from '@salesforce/sfdx-agent-sdk';
4
4
  /**
5
5
  * Shared V3 helpers for the Mastra harness's `LanguageModelV3` implementations
@@ -196,6 +196,14 @@ export type GatewayFetchWireHooks = {
196
196
  emitWireCommunication: (event: WireCommunicationLikeEvent) => void;
197
197
  /** Optional clock for test determinism. Defaults to a fresh `RealClock`. */
198
198
  clock?: Clock;
199
+ /** ID source for pairing one fetch's request and terminal response events. */
200
+ idGenerator?: UniqueIDGenerator;
201
+ /** Current provider wire shape; response text extraction is deliberately shape-specific. */
202
+ getProviderHint?: () => string;
203
+ /** Whether the harness has subscribers; avoids teeing streams nobody observes. */
204
+ hasWireCommunicationSubscribers?: () => boolean;
205
+ /** Aborted by harness shutdown so detached body observers cannot outlive their owner. */
206
+ disposeSignal?: AbortSignal;
199
207
  };
200
208
  /**
201
209
  * Wraps an `innerFetch` (defaults to `globalThis.fetch`) with a per-request
@@ -212,8 +220,11 @@ export type GatewayFetchWireHooks = {
212
220
  *
213
221
  * When `wireHooks` is supplied, every outbound call emits one
214
222
  * `WireCommunicationEvent` `request` event and one `response` event onto the
215
- * supplied callback. Wire-communication events are best-effort: a failed body
216
- * decode does NOT prevent the call from completing. Body capture is
223
+ * supplied callback. For streaming responses the response event moves from
224
+ * fetch resolution to HTTP-body terminality: an owned tee branch observes only
225
+ * recognized assistant text deltas without blocking the provider SDK's branch.
226
+ * Wire-communication events are best-effort: a failed body decode does NOT
227
+ * prevent the call from completing. Body capture is
217
228
  * intentionally bounded — only requests with a JSON body shorter than 256 KB
218
229
  * are decoded; longer bodies are recorded as `{ truncated: true, byteLength:
219
230
  * <n> }` so wire-communication captures don't blow the consumer's log file
@@ -231,15 +242,19 @@ type WireCommunicationLikeEvent = {
231
242
  url: string;
232
243
  method: string;
233
244
  model: string;
245
+ correlationId?: string;
234
246
  body?: Record<string, unknown>;
235
247
  } | {
236
248
  type: 'llm-response';
237
249
  timestamp: Date;
238
250
  model: string;
239
251
  status: number;
252
+ correlationId?: string;
240
253
  error?: Error;
241
254
  totalDurationMs?: number;
242
255
  xClientTraceId?: string;
256
+ responseText?: string;
257
+ timeToFirstTokenMs?: number;
243
258
  };
244
259
  /**
245
260
  * Reduces a V3 `LanguageModelV3ToolResultOutput` to the string shape every
@@ -2,7 +2,7 @@
2
2
  * Copyright 2026, Salesforce, Inc. All rights reserved.
3
3
  * See LICENSE.txt for license terms.
4
4
  */
5
- import { Clock, RealClock } from '@salesforce/agentic-common';
5
+ import { RealClock, UUIDGenerator } from '@salesforce/agentic-common';
6
6
  /**
7
7
  * Shared V3 helpers for the Mastra harness's `LanguageModelV3` implementations
8
8
  * — stream-shaping primitives, finish-reason / usage mappers, prompt-translation
@@ -254,8 +254,11 @@ export function mapOpenAIUsage(usage) {
254
254
  *
255
255
  * When `wireHooks` is supplied, every outbound call emits one
256
256
  * `WireCommunicationEvent` `request` event and one `response` event onto the
257
- * supplied callback. Wire-communication events are best-effort: a failed body
258
- * decode does NOT prevent the call from completing. Body capture is
257
+ * supplied callback. For streaming responses the response event moves from
258
+ * fetch resolution to HTTP-body terminality: an owned tee branch observes only
259
+ * recognized assistant text deltas without blocking the provider SDK's branch.
260
+ * Wire-communication events are best-effort: a failed body decode does NOT
261
+ * prevent the call from completing. Body capture is
259
262
  * intentionally bounded — only requests with a JSON body shorter than 256 KB
260
263
  * are decoded; longer bodies are recorded as `{ truncated: true, byteLength:
261
264
  * <n> }` so wire-communication captures don't blow the consumer's log file
@@ -283,6 +286,7 @@ export function createGatewayFetch(getHeaders, wireHooks, innerFetch = globalThi
283
286
  const model = wireHooks.getModel();
284
287
  const clock = wireHooks.clock ?? new RealClock();
285
288
  const startedAt = clock.now();
289
+ const correlationId = (wireHooks.idGenerator ?? new UUIDGenerator()).getUniqueId();
286
290
  const requestStart = performance.now();
287
291
  wireHooks.emitWireCommunication({
288
292
  type: 'llm-request',
@@ -290,19 +294,41 @@ export function createGatewayFetch(getHeaders, wireHooks, innerFetch = globalThi
290
294
  url,
291
295
  method,
292
296
  model,
297
+ correlationId,
293
298
  body: tryDecodeBody(finalInit.body),
294
299
  });
295
300
  try {
296
301
  const response = await innerFetch(input, finalInit);
297
- wireHooks.emitWireCommunication({
298
- type: 'llm-response',
299
- timestamp: clock.now(),
300
- model,
301
- status: response.status,
302
- totalDurationMs: Math.round(performance.now() - requestStart),
303
- xClientTraceId: response.headers.get('x-client-trace-id') ?? undefined,
304
- });
305
- return response;
302
+ const emitResponse = (result = {}) => {
303
+ wireHooks.emitWireCommunication({
304
+ type: 'llm-response',
305
+ timestamp: clock.now(),
306
+ model,
307
+ status: response.status,
308
+ correlationId,
309
+ totalDurationMs: Math.round(performance.now() - requestStart),
310
+ xClientTraceId: response.headers.get('x-client-trace-id') ?? undefined,
311
+ ...(result.responseText !== undefined ? { responseText: result.responseText } : {}),
312
+ ...(result.timeToFirstTokenMs !== undefined
313
+ ? { timeToFirstTokenMs: result.timeToFirstTokenMs }
314
+ : {}),
315
+ });
316
+ };
317
+ if (response.body === null || !(wireHooks.hasWireCommunicationSubscribers?.() ?? true)) {
318
+ emitResponse();
319
+ return response;
320
+ }
321
+ const [observerBody, sdkBody] = response.body.tee();
322
+ void observeResponseBody({
323
+ body: observerBody,
324
+ providerHint: wireHooks.getProviderHint?.(),
325
+ contentType: response.headers.get('content-type'),
326
+ requestSignal: finalInit.signal ?? undefined,
327
+ disposeSignal: wireHooks.disposeSignal,
328
+ clock,
329
+ onComplete: emitResponse,
330
+ }).catch(() => undefined);
331
+ return new Response(sdkBody, response);
306
332
  }
307
333
  catch (error) {
308
334
  wireHooks.emitWireCommunication({
@@ -310,6 +336,7 @@ export function createGatewayFetch(getHeaders, wireHooks, innerFetch = globalThi
310
336
  timestamp: clock.now(),
311
337
  model,
312
338
  status: 0,
339
+ correlationId,
313
340
  error: error instanceof Error ? error : new Error(String(error)),
314
341
  totalDurationMs: Math.round(performance.now() - requestStart),
315
342
  });
@@ -318,6 +345,141 @@ export function createGatewayFetch(getHeaders, wireHooks, innerFetch = globalThi
318
345
  };
319
346
  }
320
347
  const TRACE_BODY_BYTE_BUDGET = 256 * 1024;
348
+ /**
349
+ * Observes only the owned tee branch. Cancellation intentionally does not await
350
+ * `reader.cancel()`: WHATWG tee cancellation may wait for the provider SDK's
351
+ * unread branch and must never let telemetry hang a fetch.
352
+ */
353
+ async function observeResponseBody(options) {
354
+ const reader = options.body.getReader();
355
+ const decoder = new TextDecoder();
356
+ const startedAt = options.clock.now();
357
+ const text = [];
358
+ let textBytes = 0;
359
+ let rawBytes = 0;
360
+ let firstTokenAt;
361
+ let sawOutputText = false;
362
+ let sseBuffer = '';
363
+ let cancelled = false;
364
+ let resolveCancelled;
365
+ const cancellation = new Promise((resolve) => {
366
+ resolveCancelled = resolve;
367
+ });
368
+ const cancel = () => {
369
+ if (cancelled)
370
+ return;
371
+ cancelled = true;
372
+ void reader.cancel().catch(() => undefined);
373
+ try {
374
+ reader.releaseLock();
375
+ }
376
+ catch (error) {
377
+ String(error);
378
+ }
379
+ resolveCancelled?.();
380
+ };
381
+ const abort = () => cancel();
382
+ options.requestSignal?.addEventListener('abort', abort, { once: true });
383
+ options.disposeSignal?.addEventListener('abort', abort, { once: true });
384
+ if (options.requestSignal?.aborted || options.disposeSignal?.aborted)
385
+ cancel();
386
+ try {
387
+ while (!cancelled) {
388
+ const read = reader.read();
389
+ void read.catch(() => undefined);
390
+ const next = await Promise.race([read, cancellation.then(() => undefined)]);
391
+ if (cancelled || next === undefined || next.done)
392
+ break;
393
+ if (firstTokenAt === undefined)
394
+ firstTokenAt = options.clock.now().getTime() - startedAt.getTime();
395
+ if (rawBytes + next.value.byteLength > TRACE_BODY_BYTE_BUDGET) {
396
+ cancel();
397
+ break;
398
+ }
399
+ rawBytes += next.value.byteLength;
400
+ sseBuffer += decoder.decode(next.value, { stream: true });
401
+ const parsed = takeSseEvents(sseBuffer);
402
+ sseBuffer = parsed.remaining;
403
+ for (const event of parsed.events) {
404
+ const delta = outputTextDelta(options.providerHint, options.contentType, event);
405
+ if (delta === undefined)
406
+ continue;
407
+ const deltaBytes = Buffer.byteLength(delta, 'utf8');
408
+ if (textBytes + deltaBytes > TRACE_BODY_BYTE_BUDGET) {
409
+ cancel();
410
+ break;
411
+ }
412
+ sawOutputText = true;
413
+ text.push(delta);
414
+ textBytes += deltaBytes;
415
+ }
416
+ }
417
+ }
418
+ catch {
419
+ sawOutputText = false;
420
+ }
421
+ finally {
422
+ options.requestSignal?.removeEventListener('abort', abort);
423
+ options.disposeSignal?.removeEventListener('abort', abort);
424
+ if (!cancelled) {
425
+ try {
426
+ reader.releaseLock();
427
+ }
428
+ catch (error) {
429
+ String(error);
430
+ }
431
+ }
432
+ options.onComplete({
433
+ ...(sawOutputText ? { responseText: text.join('') } : {}),
434
+ ...(firstTokenAt !== undefined ? { timeToFirstTokenMs: firstTokenAt } : {}),
435
+ });
436
+ }
437
+ }
438
+ function takeSseEvents(buffer) {
439
+ const complete = buffer.lastIndexOf('\n');
440
+ if (complete === -1)
441
+ return { events: [], remaining: buffer };
442
+ const events = [];
443
+ for (const line of buffer.slice(0, complete).split(/\r?\n/)) {
444
+ if (!line.startsWith('data:'))
445
+ continue;
446
+ const data = line.slice('data:'.length).trim();
447
+ if (data.length === 0 || data === '[DONE]')
448
+ continue;
449
+ try {
450
+ events.push(JSON.parse(data));
451
+ }
452
+ catch {
453
+ // Unknown framing is deliberately not captured as text.
454
+ }
455
+ }
456
+ return { events, remaining: buffer.slice(complete + 1) };
457
+ }
458
+ /** Extract only the documented assistant output-text delta for each supported wire shape. */
459
+ function outputTextDelta(providerHint, contentType, event) {
460
+ if (!contentType?.toLowerCase().includes('text/event-stream') || typeof event !== 'object' || event === null) {
461
+ return undefined;
462
+ }
463
+ const candidate = event;
464
+ if (providerHint === 'anthropic' || providerHint === 'bedrock-anthropic') {
465
+ const delta = candidate.delta;
466
+ return candidate.type === 'content_block_delta' &&
467
+ delta?.type === 'text_delta' &&
468
+ typeof delta.text === 'string'
469
+ ? delta.text
470
+ : undefined;
471
+ }
472
+ if (providerHint === 'openai' || providerHint === 'openai-responses') {
473
+ return candidate.type === 'response.output_text.delta' && typeof candidate.delta === 'string'
474
+ ? candidate.delta
475
+ : undefined;
476
+ }
477
+ if (providerHint === 'openai-compatible') {
478
+ const content = candidate.choices?.[0]?.delta?.content;
479
+ return typeof content === 'string' ? content : undefined;
480
+ }
481
+ return undefined;
482
+ }
321
483
  function tryDecodeBody(body) {
322
484
  if (body === undefined || body === null)
323
485
  return undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/sfdx-agent-harness-mastra",
3
- "version": "0.38.0",
3
+ "version": "0.40.0",
4
4
  "description": "Mastra-backed AgentHarness implementation for @salesforce/sfdx-agent-sdk",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -41,12 +41,12 @@
41
41
  "openai": "^6.0.0"
42
42
  },
43
43
  "peerDependencies": {
44
- "@salesforce/sfdx-agent-sdk": "0.39.0"
44
+ "@salesforce/sfdx-agent-sdk": "0.41.0"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@eslint/js": "^10.0.1",
48
48
  "@salesforce/harness-conformance": "0.0.1",
49
- "@salesforce/sfdx-agent-sdk": "0.39.0",
49
+ "@salesforce/sfdx-agent-sdk": "0.41.0",
50
50
  "@types/node": "^22.20.0",
51
51
  "@vitest/coverage-istanbul": "^4.1.10",
52
52
  "@vitest/eslint-plugin": "^1.6.22",