@salesforce/sfdx-agent-harness-mastra 0.29.0 → 0.31.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,21 @@
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.31.0] - 2026-07-08
7
+
8
+ ### Fixes
9
+ - **agent-sdk,harness-mastra,harness-claude**: canonical role:tool placement for completed tool calls @W-23351939 ([#649](https://github.com/forcedotcom/agentic-dx/pull/649))
10
+
11
+ ## [0.30.0] - 2026-07-07
12
+
13
+ ### Fixes
14
+ - **harness-mastra**: getMessages() round-trips the tool-call part @W-23351939 ([#648](https://github.com/forcedotcom/agentic-dx/pull/648))
15
+
16
+ ### Chores
17
+ - **deps**: bump the mastra group across 1 directory with 5 updates ([#644](https://github.com/forcedotcom/agentic-dx/pull/644))
18
+ - **deps**: bump @anthropic-ai/bedrock-sdk from 0.31.0 to 0.32.0 ([#645](https://github.com/forcedotcom/agentic-dx/pull/645))
19
+ - **deps-dev**: bump the dev-dependencies group with 3 updates ([#642](https://github.com/forcedotcom/agentic-dx/pull/642))
20
+
6
21
  ## [0.29.0] - 2026-07-03
7
22
 
8
23
  _No changes — released alongside dependent packages._
@@ -12,7 +12,7 @@ import { MASTRA_RESOURCE_ID_KEY, MASTRA_THREAD_ID_KEY, RequestContext } from '@m
12
12
  import { LibSQLStore } from '@mastra/libsql';
13
13
  import { MCPClient } from '@mastra/mcp';
14
14
  import { AgentSDKError, AgentSDKErrorType, DEFAULT_MAX_STEPS, McpServerStatus, resolveToolApprovalMode, resolveToolApprovalPolicy, } from '@salesforce/sfdx-agent-sdk';
15
- import { HarnessBusOwner, lowerStreamInput, mcpServerConfigEqual, } from '@salesforce/sfdx-agent-sdk/harness';
15
+ import { HarnessBusOwner, lowerStreamInput, mcpServerConfigEqual, mergeToolResultsIntoAssistant, splitToolResultsIntoToolMessages, } from '@salesforce/sfdx-agent-sdk/harness';
16
16
  import { MastraApprovalCoordinator } from './mastra-approval-coordinator.js';
17
17
  import { MASTRA_BUILT_IN_TOOL_POLICIES } from './mastra-built-in-policies.js';
18
18
  import { SKILL_SEARCH_META_TOOL_NAMES, TOOL_SEARCH_META_TOOL_NAMES } from './skill-bridge.js';
@@ -1619,9 +1619,16 @@ export class MastraHarness {
1619
1619
  // silently bucket null-timestamp rows at epoch and propagate
1620
1620
  // `undefined` to consumers.
1621
1621
  let lastFallback = this.clock.now();
1622
- return messages
1623
- .filter((m) => m.role !== 'signal')
1624
- .map((m) => mapMastraToMessage(m, logBus))
1622
+ const mapped = messages.filter((m) => m.role !== 'signal').map((m) => mapMastraToMessage(m, logBus));
1623
+ // Hoist every completed tool call's `tool-result` part onto its own
1624
+ // `role: 'tool'` message so the consumer-visible layout matches the
1625
+ // cross-harness `getMessages` contract (assistant carries the
1626
+ // `tool-call`; a separate tool message carries the paired result). The
1627
+ // shared normalizer is the single site both harnesses use so the layout
1628
+ // can't drift. Runs before the createdAt backfill so the synthesized tool
1629
+ // messages (which inherit their source's `createdAt`, possibly undefined)
1630
+ // still get a populated timestamp and slot correctly in the sort.
1631
+ return splitToolResultsIntoToolMessages(mapped)
1625
1632
  .map((m) => {
1626
1633
  if (m.createdAt)
1627
1634
  return m;
@@ -1661,7 +1668,15 @@ export class MastraHarness {
1661
1668
  // record (Mastra's libsql column is non-nullable). Routes through the
1662
1669
  // shared `backfillCreatedAt` helper from `agentic-common` so the SDK
1663
1670
  // and both harnesses can't drift on the per-position step policy.
1664
- const filled = backfillCreatedAt(messages, this.clock);
1671
+ // Fold any canonical `role: 'tool'` message's result back adjacent to
1672
+ // its `tool-call` in the preceding assistant message (the inverse of the
1673
+ // `splitToolResultsIntoToolMessages` hoist `getMessages` applies). This
1674
+ // recreates the adjacency `mapMessageToMastra` coalesces into a single
1675
+ // `tool-invocation(result)`, so a `getMessages()` → `addContext()`
1676
+ // round-trip stays lossless and never double-stores the call. A no-op for
1677
+ // history that carries no tool messages.
1678
+ const merged = mergeToolResultsIntoAssistant(messages);
1679
+ const filled = backfillCreatedAt(merged, this.clock);
1665
1680
  const mapped = filled.map((m) => mapMessageToMastra(m, threadId, resourceId));
1666
1681
  await memory.saveMessages({ messages: mapped });
1667
1682
  }
@@ -15,13 +15,23 @@ import type { LogBus } from '@salesforce/agentic-common';
15
15
  */
16
16
  export declare function mapMastraToMessage(msg: MastraDBMessage, logBus?: LogBus): Message;
17
17
  /**
18
- * Converts a single Mastra message part to the SDK {@link MessagePart} equivalent.
19
- * Returns `undefined` for part types that exist in Mastra but have no SDK representation.
18
+ * Converts a single Mastra message part to zero or more SDK {@link MessagePart}s.
19
+ *
20
+ * Most Mastra parts map one-to-one, but a **completed** `tool-invocation` maps to
21
+ * *two* SDK parts — a `tool-call` (name + args) followed by a `tool-result` — because
22
+ * a single Mastra object carries both halves once its `state` transitions to `'result'`.
23
+ * Emitting both keeps `getMessages()` history harness-agnostic: the model's original
24
+ * tool call (name + args) survives on read exactly as it does on the Claude harness,
25
+ * which stores the call and result as distinct blocks (#647). Returning an array is what
26
+ * lets one input part expand to two output parts without a special case in the caller.
27
+ *
28
+ * Returns an empty array for part types that exist in Mastra but have no SDK
29
+ * representation (e.g. `source`, `step-start`, `partial-call`, `data-*`).
20
30
  *
21
31
  * @param logBus - When supplied, unrecognized part types emit a `warn` log via this bus.
22
32
  * Tests that don't care about logging can omit it.
23
33
  */
24
- export declare function mapMastraPartToMessagePart(part: MastraMessagePart, logBus?: LogBus): MessagePart | undefined;
34
+ export declare function mapMastraPartToMessagePart(part: MastraMessagePart, logBus?: LogBus): MessagePart[];
25
35
  /**
26
36
  * Converts an SDK {@link Message} into the Mastra persisted message format.
27
37
  * The `role` field is normalised: `'tool'` role (which has no Mastra equivalent)
@@ -29,6 +29,33 @@ function formatApproxSize(base64Data) {
29
29
  return `${kib.toFixed(1)} KB`;
30
30
  return `${(kib / 1024).toFixed(1)} MB`;
31
31
  }
32
+ /**
33
+ * Builds the single Mastra `tool-invocation` part that represents a completed
34
+ * tool call, carrying both the original `args` and the outcome. A successful
35
+ * result maps to `state: 'result'` with `result`; an errored result
36
+ * (`isError: true`) maps to `state: 'output-error'` — Mastra's native shape for
37
+ * a failed invocation. For the error case both the structured `result` AND a
38
+ * stringified `errorText` are written: the read mapper prefers `result` so a
39
+ * non-string error payload round-trips as an object, and `errorText` is Mastra's
40
+ * native error slot (used as the fallback for natively-persisted error
41
+ * invocations that carry only that). This is the write-side counterpart of the
42
+ * `mapMastraPartToMessagePart` expansion, so an errored tool result survives a
43
+ * `getMessages()` → `addContext()` round-trip with its `isError` flag and its
44
+ * `result` structure intact (the cross-harness `isError` contract, #647).
45
+ */
46
+ function buildCompletedToolInvocation(toolCallId, toolName, args, result) {
47
+ if (result.isError === true) {
48
+ const errorText = typeof result.result === 'string' ? result.result : JSON.stringify(result.result);
49
+ return {
50
+ type: 'tool-invocation',
51
+ toolInvocation: { state: 'output-error', toolCallId, toolName, args, result: result.result, errorText },
52
+ };
53
+ }
54
+ return {
55
+ type: 'tool-invocation',
56
+ toolInvocation: { state: 'result', toolCallId, toolName, args, result: result.result },
57
+ };
58
+ }
32
59
  /**
33
60
  * Converts a Mastra persisted message into the SDK's {@link Message} type.
34
61
  *
@@ -47,10 +74,7 @@ export function mapMastraToMessage(msg, logBus) {
47
74
  }
48
75
  const parts = [];
49
76
  for (const part of msg.content.parts) {
50
- const mapped = mapMastraPartToMessagePart(part, logBus);
51
- if (mapped) {
52
- parts.push(mapped);
53
- }
77
+ parts.push(...mapMastraPartToMessagePart(part, logBus));
54
78
  }
55
79
  const consolidated = consolidateAdjacentTextParts(parts);
56
80
  const isSingleText = consolidated.length === 1 && consolidated[0].type === 'text';
@@ -85,8 +109,18 @@ function consolidateAdjacentTextParts(parts) {
85
109
  return result;
86
110
  }
87
111
  /**
88
- * Converts a single Mastra message part to the SDK {@link MessagePart} equivalent.
89
- * Returns `undefined` for part types that exist in Mastra but have no SDK representation.
112
+ * Converts a single Mastra message part to zero or more SDK {@link MessagePart}s.
113
+ *
114
+ * Most Mastra parts map one-to-one, but a **completed** `tool-invocation` maps to
115
+ * *two* SDK parts — a `tool-call` (name + args) followed by a `tool-result` — because
116
+ * a single Mastra object carries both halves once its `state` transitions to `'result'`.
117
+ * Emitting both keeps `getMessages()` history harness-agnostic: the model's original
118
+ * tool call (name + args) survives on read exactly as it does on the Claude harness,
119
+ * which stores the call and result as distinct blocks (#647). Returning an array is what
120
+ * lets one input part expand to two output parts without a special case in the caller.
121
+ *
122
+ * Returns an empty array for part types that exist in Mastra but have no SDK
123
+ * representation (e.g. `source`, `step-start`, `partial-call`, `data-*`).
90
124
  *
91
125
  * @param logBus - When supplied, unrecognized part types emit a `warn` log via this bus.
92
126
  * Tests that don't care about logging can omit it.
@@ -94,60 +128,98 @@ function consolidateAdjacentTextParts(parts) {
94
128
  export function mapMastraPartToMessagePart(part, logBus) {
95
129
  switch (part.type) {
96
130
  case 'text':
97
- return { type: 'text', text: part.text };
131
+ return [{ type: 'text', text: part.text }];
98
132
  case 'reasoning':
99
- return { type: 'reasoning', text: part.reasoning };
133
+ return [{ type: 'reasoning', text: part.reasoning }];
100
134
  case 'tool-invocation': {
101
135
  const inv = part.toolInvocation;
102
136
  if (inv.state === 'partial-call') {
103
- return undefined;
104
- }
105
- if (inv.state === 'result') {
106
- return {
107
- type: 'tool-result',
108
- toolCallId: inv.toolCallId,
109
- toolName: inv.toolName,
110
- result: inv.result,
111
- };
137
+ return [];
112
138
  }
113
- return {
139
+ const toolCall = {
114
140
  type: 'tool-call',
115
141
  toolCallId: inv.toolCallId,
116
142
  toolName: inv.toolName,
117
- args: inv.args,
143
+ args: (inv.args ?? {}),
118
144
  };
145
+ // A completed invocation carries both the original call (name + args)
146
+ // and its outcome; surface both so history round-trips the call the
147
+ // model made, not just its result. Mastra models a completed call in
148
+ // three states: `result` (success) and `output-error` / `output-denied`
149
+ // (failure). All three expand to a tool-call + tool-result pair; the
150
+ // two error states set `isError: true`, matching the SDK
151
+ // `ToolResultPart` shape and the cross-harness `isError` round-trip
152
+ // contract (#647). The shared `splitToolResultsIntoToolMessages`
153
+ // normalizer (run in `getMessages`) then hoists the tool-result onto
154
+ // its own `role: 'tool'` message.
155
+ //
156
+ // KNOWN BEHAVIOR: a completed invocation always carries `args` (the
157
+ // model's real call), so the expanded `tool-call` reflects what the
158
+ // model actually invoked. The one exception is inconsistent history a
159
+ // consumer hand-constructs via `addContext` — a lone `role: 'tool'`
160
+ // result with no matching `tool-call`. `mergeToolResultsIntoAssistant`
161
+ // can't fold it (no call to attach to), so it persists as a standalone
162
+ // `tool-invocation(result)` with `args: {}`, and this read then
163
+ // fabricates a `tool-call` with those empty args. The SDK never emits
164
+ // that shape itself; it's an accepted edge of accepting arbitrary
165
+ // caller-supplied history.
166
+ if (inv.state === 'result') {
167
+ return [
168
+ toolCall,
169
+ { type: 'tool-result', toolCallId: inv.toolCallId, toolName: inv.toolName, result: inv.result },
170
+ ];
171
+ }
172
+ if (inv.state === 'output-error' || inv.state === 'output-denied') {
173
+ // Prefer the structured `result` (the write path preserves it
174
+ // alongside `errorText`) so a non-string error payload round-trips
175
+ // as an object, not a JSON string; fall back to `errorText` for
176
+ // natively-persisted error invocations that carry only that.
177
+ return [
178
+ toolCall,
179
+ {
180
+ type: 'tool-result',
181
+ toolCallId: inv.toolCallId,
182
+ toolName: inv.toolName,
183
+ result: inv.result ?? inv.errorText,
184
+ isError: true,
185
+ },
186
+ ];
187
+ }
188
+ // `call`, `approval-requested`, `approval-responded`: the call was
189
+ // made but has no outcome yet — surface the tool-call alone.
190
+ return [toolCall];
119
191
  }
120
192
  case 'file': {
121
193
  // Read path: base64 data is returned verbatim from LibSQL — see ARCHITECTURE.md → "Memory Strategy"
122
194
  // for the storage-cost caveat (no blob offloading; full encoded size per attachment per row).
123
195
  const fileName = readSalesforceFileName(part.providerMetadata);
124
196
  if (part.mimeType === 'image/png' || part.mimeType === 'image/jpeg') {
125
- return { type: 'image', mimeType: part.mimeType, data: part.data, ...(fileName ? { fileName } : {}) };
197
+ return [{ type: 'image', mimeType: part.mimeType, data: part.data, ...(fileName ? { fileName } : {}) }];
126
198
  }
127
199
  if (part.mimeType === 'application/pdf') {
128
- return { type: 'file', mimeType: part.mimeType, data: part.data, ...(fileName ? { fileName } : {}) };
200
+ return [{ type: 'file', mimeType: part.mimeType, data: part.data, ...(fileName ? { fileName } : {}) }];
129
201
  }
130
202
  logBus?.warn('Skipping file MastraMessagePart with unsupported mimeType; omitted from message history', {
131
203
  mimeType: part.mimeType,
132
204
  hint: 'Only image/png, image/jpeg, and application/pdf map to SDK message parts.',
133
205
  });
134
- return undefined;
206
+ return [];
135
207
  }
136
208
  // Part types that exist in Mastra but have no SDK equivalent yet.
137
209
  case 'source':
138
210
  case 'step-start':
139
- return undefined;
211
+ return [];
140
212
  default:
141
213
  // AI SDK v5 data parts use a `data-*` type prefix for framework-specific
142
214
  // metadata (e.g. workspace info). These are harness-internal and expected.
143
215
  if (part.type.startsWith('data-')) {
144
- return undefined;
216
+ return [];
145
217
  }
146
218
  logBus?.warn('Skipping unrecognized MastraMessagePart; omitted from message history', {
147
219
  partType: part.type,
148
220
  hint: 'Update mapMastraPartToMessagePart to handle this type if it should be mapped.',
149
221
  });
150
- return undefined;
222
+ return [];
151
223
  }
152
224
  }
153
225
  /**
@@ -161,7 +233,22 @@ export function mapMessageToMastra(msg, threadId, resourceId) {
161
233
  parts.push({ type: 'text', text: msg.content });
162
234
  }
163
235
  else {
164
- for (const part of msg.content) {
236
+ const content = msg.content;
237
+ for (let i = 0; i < content.length; i++) {
238
+ const part = content[i];
239
+ const next = content[i + 1];
240
+ // Coalesce an adjacent tool-call + tool-result for the same toolCallId
241
+ // into ONE Mastra `tool-invocation(result)` carrying both `args` and
242
+ // `result`. This mirrors how Mastra natively persists a completed call
243
+ // (its MessageList merges the call and result into a single object) and
244
+ // is the exact inverse of the read-path expansion in
245
+ // `mapMastraPartToMessagePart`, so a `getMessages()` → `addContext()`
246
+ // round-trip is lossless and never double-counts the call (#647).
247
+ if (part.type === 'tool-call' && next?.type === 'tool-result' && next.toolCallId === part.toolCallId) {
248
+ parts.push(buildCompletedToolInvocation(part.toolCallId, part.toolName, part.args, next));
249
+ i++; // consumed the paired tool-result
250
+ continue;
251
+ }
165
252
  parts.push(mapMessagePartToMastra(part));
166
253
  }
167
254
  }
@@ -212,16 +299,7 @@ export function mapMessagePartToMastra(part) {
212
299
  },
213
300
  };
214
301
  case 'tool-result':
215
- return {
216
- type: 'tool-invocation',
217
- toolInvocation: {
218
- state: 'result',
219
- toolCallId: part.toolCallId,
220
- toolName: part.toolName,
221
- args: {},
222
- result: part.result,
223
- },
224
- };
302
+ return buildCompletedToolInvocation(part.toolCallId, part.toolName, {}, part);
225
303
  case 'image':
226
304
  case 'file':
227
305
  // Mastra persists a single `file` UIMessage part for both. The SDK's `fileName` has no
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/sfdx-agent-harness-mastra",
3
- "version": "0.29.0",
3
+ "version": "0.31.0",
4
4
  "description": "Mastra-backed AgentHarness implementation for @salesforce/sfdx-agent-sdk",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -29,22 +29,22 @@
29
29
  ],
30
30
  "dependencies": {
31
31
  "@ai-sdk/provider": "^3.0.10",
32
- "@anthropic-ai/bedrock-sdk": "^0.31.0",
32
+ "@anthropic-ai/bedrock-sdk": "^0.32.0",
33
33
  "@anthropic-ai/sdk": "^0.106.0",
34
- "@mastra/core": "^1.47.0",
35
- "@mastra/libsql": "^1.14.2",
36
- "@mastra/mcp": "^1.10.0",
37
- "@mastra/memory": "^1.21.2",
34
+ "@mastra/core": "^1.49.0",
35
+ "@mastra/libsql": "^1.15.0",
36
+ "@mastra/mcp": "^1.13.0",
37
+ "@mastra/memory": "^1.22.1",
38
38
  "@mastra/schema-compat": "^1.2.11",
39
39
  "@salesforce/agentic-common": "0.13.0",
40
40
  "openai": "^6.0.0"
41
41
  },
42
42
  "peerDependencies": {
43
- "@salesforce/sfdx-agent-sdk": "0.30.0"
43
+ "@salesforce/sfdx-agent-sdk": "0.32.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@eslint/js": "^10.0.1",
47
- "@salesforce/sfdx-agent-sdk": "0.30.0",
47
+ "@salesforce/sfdx-agent-sdk": "0.32.0",
48
48
  "@types/node": "^22.20.0",
49
49
  "@vitest/coverage-istanbul": "^4.1.8",
50
50
  "@vitest/eslint-plugin": "^1.6.20",
@@ -55,11 +55,11 @@
55
55
  "eslint-plugin-n": "^18.2.1",
56
56
  "globals": "^17.6.0",
57
57
  "lint-staged": "^17.0.7",
58
- "prettier": "^3.9.0",
58
+ "prettier": "^3.9.4",
59
59
  "rimraf": "^6.1.3",
60
- "tsx": "^4.22.4",
60
+ "tsx": "^4.23.0",
61
61
  "typescript": "^6.0.3",
62
- "typescript-eslint": "^8.61.0",
62
+ "typescript-eslint": "^8.62.1",
63
63
  "vitest": "^4.1.8"
64
64
  },
65
65
  "engines": {