@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 +5 -0
- package/dist/mastra-harness.js +20 -5
- package/dist/mastra-message-mapper.js +68 -23
- package/package.json +3 -3
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
|
package/dist/mastra-harness.js
CHANGED
|
@@ -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
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|