@salesforce/sfdx-agent-harness-mastra 0.30.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,11 @@
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
+
6
11
  ## [0.30.0] - 2026-07-07
7
12
 
8
13
  ### Fixes
@@ -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
  }
@@ -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
  *
@@ -115,15 +142,51 @@ export function mapMastraPartToMessagePart(part, logBus) {
115
142
  toolName: inv.toolName,
116
143
  args: (inv.args ?? {}),
117
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.
118
166
  if (inv.state === 'result') {
119
- // A completed invocation carries both the original call (name + args)
120
- // and its result; surface both so history round-trips the call the
121
- // model made, not just its outcome.
122
167
  return [
123
168
  toolCall,
124
169
  { type: 'tool-result', toolCallId: inv.toolCallId, toolName: inv.toolName, result: inv.result },
125
170
  ];
126
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.
127
190
  return [toolCall];
128
191
  }
129
192
  case 'file': {
@@ -182,16 +245,7 @@ export function mapMessageToMastra(msg, threadId, resourceId) {
182
245
  // `mapMastraPartToMessagePart`, so a `getMessages()` → `addContext()`
183
246
  // round-trip is lossless and never double-counts the call (#647).
184
247
  if (part.type === 'tool-call' && next?.type === 'tool-result' && next.toolCallId === part.toolCallId) {
185
- parts.push({
186
- type: 'tool-invocation',
187
- toolInvocation: {
188
- state: 'result',
189
- toolCallId: part.toolCallId,
190
- toolName: part.toolName,
191
- args: part.args,
192
- result: next.result,
193
- },
194
- });
248
+ parts.push(buildCompletedToolInvocation(part.toolCallId, part.toolName, part.args, next));
195
249
  i++; // consumed the paired tool-result
196
250
  continue;
197
251
  }
@@ -245,16 +299,7 @@ export function mapMessagePartToMastra(part) {
245
299
  },
246
300
  };
247
301
  case 'tool-result':
248
- return {
249
- type: 'tool-invocation',
250
- toolInvocation: {
251
- state: 'result',
252
- toolCallId: part.toolCallId,
253
- toolName: part.toolName,
254
- args: {},
255
- result: part.result,
256
- },
257
- };
302
+ return buildCompletedToolInvocation(part.toolCallId, part.toolName, {}, part);
258
303
  case 'image':
259
304
  case 'file':
260
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.30.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",
@@ -40,11 +40,11 @@
40
40
  "openai": "^6.0.0"
41
41
  },
42
42
  "peerDependencies": {
43
- "@salesforce/sfdx-agent-sdk": "0.31.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.31.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",