@salesforce/sfdx-agent-harness-mastra 0.18.0 → 0.20.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 +14 -0
- package/README.md +6 -3
- package/dist/mastra-approval-coordinator.d.ts +96 -8
- package/dist/mastra-approval-coordinator.js +160 -27
- package/dist/mastra-event-adapter.d.ts +3 -2
- package/dist/mastra-event-adapter.js +23 -6
- package/dist/mastra-harness.d.ts +16 -0
- package/dist/mastra-harness.js +51 -0
- package/dist/mcp-config-mapper.js +9 -0
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,20 @@
|
|
|
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.20.0] - 2026-06-15
|
|
7
|
+
|
|
8
|
+
### Fixes
|
|
9
|
+
- **harness-claude,harness-mastra,agent-sdk**: idempotent settle + quiet dispose (#589) ([#598](https://github.com/forcedotcom/agentic-dx/pull/598))
|
|
10
|
+
|
|
11
|
+
## [0.19.0] - 2026-06-12
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
- **agent-sdk,harness-mastra,harness-claude**: add tool-call-delta ChatEvent for streaming tool-call args @W-22965697@ ([#597](https://github.com/forcedotcom/agentic-dx/pull/597))
|
|
15
|
+
- **agent-sdk,harness-mastra,harness-claude**: add tool-progress ChatEvent variant @W-22951127@ ([#594](https://github.com/forcedotcom/agentic-dx/pull/594))
|
|
16
|
+
|
|
17
|
+
### Fixes
|
|
18
|
+
- **harness-claude**: map SDKResultSuccess.is_error to ChatEvent.error ([#593](https://github.com/forcedotcom/agentic-dx/pull/593))
|
|
19
|
+
|
|
6
20
|
## [0.18.0] - 2026-06-11
|
|
7
21
|
|
|
8
22
|
### Fixes
|
package/README.md
CHANGED
|
@@ -203,9 +203,12 @@ active `eventStream`:
|
|
|
203
203
|
{ type: 'finish', finishReason: 'error' }
|
|
204
204
|
```
|
|
205
205
|
|
|
206
|
-
Subsequent `approveToolCall` / `declineToolCall` / `submitToolResult` calls for the timed-out tool
|
|
207
|
-
coordinator
|
|
208
|
-
|
|
206
|
+
Subsequent `approveToolCall` / `declineToolCall` / `submitToolResult` calls for the timed-out tool are silent no-ops
|
|
207
|
+
(issue #589 idempotent settle) — the coordinator already auto-resolved them, so the consumer's intended outcome is
|
|
208
|
+
already true and there's nothing to fail. Only a `toolCallId` the coordinator never saw at all (typo, wrong session,
|
|
209
|
+
called before `tool-approval-request` ever fired) throws, and that throw is an `AgentSDKError` whose
|
|
210
|
+
`type === AgentSDKErrorType.TOOL_CALL_NOT_FOUND`. The granularity is per `toolCallId` — each emitted approval arms its
|
|
211
|
+
own timer; the first one to fire tears down the whole turn (since Mastra can't continue the suspended run after timeout
|
|
209
212
|
anyway). To observe the timeout, keep iterating the `eventStream` past the `tool-approval-request` event so the
|
|
210
213
|
synthetic terminal pair lands in your loop:
|
|
211
214
|
|
|
@@ -128,8 +128,11 @@ export declare class MastraApprovalCoordinator {
|
|
|
128
128
|
private readonly consumerSettled;
|
|
129
129
|
/**
|
|
130
130
|
* ToolCallIds the coordinator auto-resolved (timeout / abort / dispose
|
|
131
|
-
* / consumer-tool auto-approve). A subsequent settle for
|
|
132
|
-
*
|
|
131
|
+
* / pump-error / consumer-tool auto-approve). A subsequent settle for
|
|
132
|
+
* these ids is a silent no-op via the idempotent-settle contract
|
|
133
|
+
* (issue #589) — the lost race is unactionable from the consumer's
|
|
134
|
+
* side, and the methods are void-typed so there's no return value to
|
|
135
|
+
* communicate the no-op.
|
|
133
136
|
*/
|
|
134
137
|
private readonly settledByCoordinator;
|
|
135
138
|
/** Per-toolCallId approval-timeout timers (armed when an approval is emitted). */
|
|
@@ -142,6 +145,29 @@ export declare class MastraApprovalCoordinator {
|
|
|
142
145
|
* `agent.approveToolCall(...)` / `declineToolCall(...)`.
|
|
143
146
|
*/
|
|
144
147
|
private currentRunId;
|
|
148
|
+
/**
|
|
149
|
+
* `toolCallId` of the MCP tool call currently in-flight on this turn,
|
|
150
|
+
* or `null` when no MCP tool is awaiting a result. Tracked at
|
|
151
|
+
* `routeEnriched` time: set when a MCP-namespaced `tool-call` arrives,
|
|
152
|
+
* cleared on the matching `tool-result`.
|
|
153
|
+
*
|
|
154
|
+
* Used by {@link emitMcpProgress} to attribute incoming
|
|
155
|
+
* `notifications/progress` (which only carry a `progressToken`) to the
|
|
156
|
+
* right `toolCallId` for the `tool-progress` ChatEvent.
|
|
157
|
+
*
|
|
158
|
+
* Mastra serializes MCP tool execution per workflow step, so at most
|
|
159
|
+
* one MCP tool call is in flight per coordinator at any time —
|
|
160
|
+
* matching the "currentMcpToolCallId" field's scalar shape.
|
|
161
|
+
*/
|
|
162
|
+
private currentMcpToolCallId;
|
|
163
|
+
/**
|
|
164
|
+
* Captures the (Mastra-namespaced) tool name for each `toolCallId`
|
|
165
|
+
* routed through `routeEnriched`. Used by {@link emitMcpProgress} to
|
|
166
|
+
* stamp `toolName` on the `tool-progress` ChatEvent so AG-UI Activity
|
|
167
|
+
* translators don't have to consult the originating `tool-call` event
|
|
168
|
+
* to route progress chunks.
|
|
169
|
+
*/
|
|
170
|
+
private readonly toolNameForCallId;
|
|
145
171
|
/**
|
|
146
172
|
* ToolCallIds Mastra is currently suspended on. Mastra retains a separate
|
|
147
173
|
* workflow snapshot step per id, so the pump must drive one resume per
|
|
@@ -158,6 +184,15 @@ export declare class MastraApprovalCoordinator {
|
|
|
158
184
|
* parker invocation for the same id.
|
|
159
185
|
*/
|
|
160
186
|
private readonly preSubmittedByToolCallId;
|
|
187
|
+
/**
|
|
188
|
+
* ToolCallIds the consumer has settled via {@link submitToolResult},
|
|
189
|
+
* or that the coordinator drained at teardown time. Used as the
|
|
190
|
+
* idempotent-settle ledger for the consumer-tool path (parallel to
|
|
191
|
+
* `consumerSettled` + `settledByCoordinator` on the approve/decline
|
|
192
|
+
* path). A subsequent `submitToolResult` for these ids is a silent
|
|
193
|
+
* no-op rather than throwing `TOOL_CALL_NOT_FOUND` (issue #589).
|
|
194
|
+
*/
|
|
195
|
+
private readonly consumerToolSettled;
|
|
161
196
|
/**
|
|
162
197
|
* Callbacks invoked exactly once during teardown (`abort` or `dispose`,
|
|
163
198
|
* whichever fires first, or natural pump exit).
|
|
@@ -175,6 +210,33 @@ export declare class MastraApprovalCoordinator {
|
|
|
175
210
|
private timedOut;
|
|
176
211
|
constructor(opts: MastraApprovalCoordinatorOptions);
|
|
177
212
|
initialSink(): GenSink<ChatEvent>;
|
|
213
|
+
/**
|
|
214
|
+
* Mastra's current run id. The harness's MCP-progress dispatcher uses
|
|
215
|
+
* this to filter incoming `notifications/progress` to the coordinator
|
|
216
|
+
* whose run produced the originating tool call (the upstream server
|
|
217
|
+
* received the progressToken Mastra injected from `context.runId`).
|
|
218
|
+
*/
|
|
219
|
+
getCurrentRunId(): string;
|
|
220
|
+
/**
|
|
221
|
+
* Issue #591: dispatch an MCP `notifications/progress` notification
|
|
222
|
+
* received via `mcpClient.progress.onUpdate(...)` to the consumer's
|
|
223
|
+
* eventStream as a `tool-progress` ChatEvent.
|
|
224
|
+
*
|
|
225
|
+
* Caller (the harness MCP-progress listener) is responsible for
|
|
226
|
+
* filtering by `progressToken === coordinator.getCurrentRunId()` so this
|
|
227
|
+
* method only sees notifications belonging to its own turn.
|
|
228
|
+
*
|
|
229
|
+
* Drops the notification (no event surfaced) when no MCP tool call is
|
|
230
|
+
* currently in flight — the only path that produces `currentMcpToolCallId
|
|
231
|
+
* === null` after a turn started is "progress arrived after the matching
|
|
232
|
+
* tool-result." Losing a stragglier is preferable to surfacing a
|
|
233
|
+
* progress event without a meaningful toolCallId.
|
|
234
|
+
*/
|
|
235
|
+
emitMcpProgress(params: {
|
|
236
|
+
progress: number;
|
|
237
|
+
total?: number;
|
|
238
|
+
message?: string;
|
|
239
|
+
}): void;
|
|
178
240
|
onTeardown(callback: () => void): void;
|
|
179
241
|
private runTeardownCallbacks;
|
|
180
242
|
/**
|
|
@@ -188,13 +250,39 @@ export declare class MastraApprovalCoordinator {
|
|
|
188
250
|
/**
|
|
189
251
|
* Single teardown path for abort / dispose / approval-timeout / pump-
|
|
190
252
|
* error. Drains timers, rejects pending consumer-tool parkers, marks
|
|
191
|
-
* every emitted approval
|
|
192
|
-
* suspended run, pushes the
|
|
193
|
-
*
|
|
253
|
+
* every emitted approval and consumer-tool toolCallId as
|
|
254
|
+
* coordinator-settled, releases Mastra's suspended run, pushes the
|
|
255
|
+
* typed `error` + `finish('error')` pair on the sink, and fires
|
|
256
|
+
* teardown callbacks.
|
|
257
|
+
*
|
|
258
|
+
* Every teardown reason — including `'dispose'` — pushes both events
|
|
259
|
+
* (issue #589). Two reasons:
|
|
260
|
+
*
|
|
261
|
+
* - **Consumers route on `error.code`, not on the *presence* of an
|
|
262
|
+
* error event.** AFV's `chat-stream-service.ts` maps
|
|
263
|
+
* `error.code === AgentSDKErrorType.DISPOSED` → its internal
|
|
264
|
+
* `AGENT_CANCELLED` and reaps the turn as cancelled (rather than
|
|
265
|
+
* completed or failed). Suppressing the dispose error event would
|
|
266
|
+
* delete that signal and a turn that was actually torn down would
|
|
267
|
+
* record as successful in their telemetry pipeline. The original
|
|
268
|
+
* bug (issue #589 failure 2) was *untyped* error noise —
|
|
269
|
+
* `error.code` is already typed as `AgentSDKErrorType.DISPOSED`,
|
|
270
|
+
* which gives consumers a clean discriminator without
|
|
271
|
+
* string-matching.
|
|
272
|
+
*
|
|
273
|
+
* - **The SDK invariant requires every chat turn to end with one
|
|
274
|
+
* terminal `FinishEvent`.** `DefaultChatSession.wrapEventStream`
|
|
275
|
+
* only synthesizes a synthetic `finish` when an error landed on
|
|
276
|
+
* the stream; if dispose ended the sink without an error, the
|
|
277
|
+
* turn would end with neither a natural finish nor a synthetic
|
|
278
|
+
* one — breaking the SDK contract pinned in
|
|
279
|
+
* `packages/sfdx-agent-sdk/ARCHITECTURE.md` →
|
|
280
|
+
* "Critical Invariants" → "Every chat turn ends with exactly one
|
|
281
|
+
* terminal FinishEvent".
|
|
194
282
|
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
283
|
+
* Mirrors the Claude coordinator's `teardown` so the abort / dispose
|
|
284
|
+
* / timeout / pump-error paths can't drift out of step on either
|
|
285
|
+
* harness — issue #589 lands the same shape on both.
|
|
198
286
|
*/
|
|
199
287
|
private teardown;
|
|
200
288
|
approve(toolCallId: string): void;
|
|
@@ -64,8 +64,11 @@ export class MastraApprovalCoordinator {
|
|
|
64
64
|
consumerSettled = new Set();
|
|
65
65
|
/**
|
|
66
66
|
* ToolCallIds the coordinator auto-resolved (timeout / abort / dispose
|
|
67
|
-
* / consumer-tool auto-approve). A subsequent settle for
|
|
68
|
-
*
|
|
67
|
+
* / pump-error / consumer-tool auto-approve). A subsequent settle for
|
|
68
|
+
* these ids is a silent no-op via the idempotent-settle contract
|
|
69
|
+
* (issue #589) — the lost race is unactionable from the consumer's
|
|
70
|
+
* side, and the methods are void-typed so there's no return value to
|
|
71
|
+
* communicate the no-op.
|
|
69
72
|
*/
|
|
70
73
|
settledByCoordinator = new Set();
|
|
71
74
|
/** Per-toolCallId approval-timeout timers (armed when an approval is emitted). */
|
|
@@ -78,6 +81,29 @@ export class MastraApprovalCoordinator {
|
|
|
78
81
|
* `agent.approveToolCall(...)` / `declineToolCall(...)`.
|
|
79
82
|
*/
|
|
80
83
|
currentRunId = '';
|
|
84
|
+
/**
|
|
85
|
+
* `toolCallId` of the MCP tool call currently in-flight on this turn,
|
|
86
|
+
* or `null` when no MCP tool is awaiting a result. Tracked at
|
|
87
|
+
* `routeEnriched` time: set when a MCP-namespaced `tool-call` arrives,
|
|
88
|
+
* cleared on the matching `tool-result`.
|
|
89
|
+
*
|
|
90
|
+
* Used by {@link emitMcpProgress} to attribute incoming
|
|
91
|
+
* `notifications/progress` (which only carry a `progressToken`) to the
|
|
92
|
+
* right `toolCallId` for the `tool-progress` ChatEvent.
|
|
93
|
+
*
|
|
94
|
+
* Mastra serializes MCP tool execution per workflow step, so at most
|
|
95
|
+
* one MCP tool call is in flight per coordinator at any time —
|
|
96
|
+
* matching the "currentMcpToolCallId" field's scalar shape.
|
|
97
|
+
*/
|
|
98
|
+
currentMcpToolCallId = null;
|
|
99
|
+
/**
|
|
100
|
+
* Captures the (Mastra-namespaced) tool name for each `toolCallId`
|
|
101
|
+
* routed through `routeEnriched`. Used by {@link emitMcpProgress} to
|
|
102
|
+
* stamp `toolName` on the `tool-progress` ChatEvent so AG-UI Activity
|
|
103
|
+
* translators don't have to consult the originating `tool-call` event
|
|
104
|
+
* to route progress chunks.
|
|
105
|
+
*/
|
|
106
|
+
toolNameForCallId = new Map();
|
|
81
107
|
/**
|
|
82
108
|
* ToolCallIds Mastra is currently suspended on. Mastra retains a separate
|
|
83
109
|
* workflow snapshot step per id, so the pump must drive one resume per
|
|
@@ -94,6 +120,15 @@ export class MastraApprovalCoordinator {
|
|
|
94
120
|
* parker invocation for the same id.
|
|
95
121
|
*/
|
|
96
122
|
preSubmittedByToolCallId = new Map();
|
|
123
|
+
/**
|
|
124
|
+
* ToolCallIds the consumer has settled via {@link submitToolResult},
|
|
125
|
+
* or that the coordinator drained at teardown time. Used as the
|
|
126
|
+
* idempotent-settle ledger for the consumer-tool path (parallel to
|
|
127
|
+
* `consumerSettled` + `settledByCoordinator` on the approve/decline
|
|
128
|
+
* path). A subsequent `submitToolResult` for these ids is a silent
|
|
129
|
+
* no-op rather than throwing `TOOL_CALL_NOT_FOUND` (issue #589).
|
|
130
|
+
*/
|
|
131
|
+
consumerToolSettled = new Set();
|
|
97
132
|
/**
|
|
98
133
|
* Callbacks invoked exactly once during teardown (`abort` or `dispose`,
|
|
99
134
|
* whichever fires first, or natural pump exit).
|
|
@@ -123,6 +158,43 @@ export class MastraApprovalCoordinator {
|
|
|
123
158
|
initialSink() {
|
|
124
159
|
return this.initial;
|
|
125
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* Mastra's current run id. The harness's MCP-progress dispatcher uses
|
|
163
|
+
* this to filter incoming `notifications/progress` to the coordinator
|
|
164
|
+
* whose run produced the originating tool call (the upstream server
|
|
165
|
+
* received the progressToken Mastra injected from `context.runId`).
|
|
166
|
+
*/
|
|
167
|
+
getCurrentRunId() {
|
|
168
|
+
return this.currentRunId;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Issue #591: dispatch an MCP `notifications/progress` notification
|
|
172
|
+
* received via `mcpClient.progress.onUpdate(...)` to the consumer's
|
|
173
|
+
* eventStream as a `tool-progress` ChatEvent.
|
|
174
|
+
*
|
|
175
|
+
* Caller (the harness MCP-progress listener) is responsible for
|
|
176
|
+
* filtering by `progressToken === coordinator.getCurrentRunId()` so this
|
|
177
|
+
* method only sees notifications belonging to its own turn.
|
|
178
|
+
*
|
|
179
|
+
* Drops the notification (no event surfaced) when no MCP tool call is
|
|
180
|
+
* currently in flight — the only path that produces `currentMcpToolCallId
|
|
181
|
+
* === null` after a turn started is "progress arrived after the matching
|
|
182
|
+
* tool-result." Losing a stragglier is preferable to surfacing a
|
|
183
|
+
* progress event without a meaningful toolCallId.
|
|
184
|
+
*/
|
|
185
|
+
emitMcpProgress(params) {
|
|
186
|
+
if (this.aborted || this.disposed)
|
|
187
|
+
return;
|
|
188
|
+
const toolCallId = this.currentMcpToolCallId;
|
|
189
|
+
if (!toolCallId)
|
|
190
|
+
return;
|
|
191
|
+
this.initial.push({
|
|
192
|
+
type: 'tool-progress',
|
|
193
|
+
toolCallId,
|
|
194
|
+
toolName: this.toolNameForCallId.get(toolCallId) ?? 'unknown',
|
|
195
|
+
output: { ...params },
|
|
196
|
+
});
|
|
197
|
+
}
|
|
126
198
|
onTeardown(callback) {
|
|
127
199
|
if (this.aborted || this.disposed) {
|
|
128
200
|
queueMicrotask(callback);
|
|
@@ -169,18 +241,52 @@ export class MastraApprovalCoordinator {
|
|
|
169
241
|
/**
|
|
170
242
|
* Single teardown path for abort / dispose / approval-timeout / pump-
|
|
171
243
|
* error. Drains timers, rejects pending consumer-tool parkers, marks
|
|
172
|
-
* every emitted approval
|
|
173
|
-
* suspended run, pushes the
|
|
174
|
-
*
|
|
244
|
+
* every emitted approval and consumer-tool toolCallId as
|
|
245
|
+
* coordinator-settled, releases Mastra's suspended run, pushes the
|
|
246
|
+
* typed `error` + `finish('error')` pair on the sink, and fires
|
|
247
|
+
* teardown callbacks.
|
|
248
|
+
*
|
|
249
|
+
* Every teardown reason — including `'dispose'` — pushes both events
|
|
250
|
+
* (issue #589). Two reasons:
|
|
175
251
|
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
252
|
+
* - **Consumers route on `error.code`, not on the *presence* of an
|
|
253
|
+
* error event.** AFV's `chat-stream-service.ts` maps
|
|
254
|
+
* `error.code === AgentSDKErrorType.DISPOSED` → its internal
|
|
255
|
+
* `AGENT_CANCELLED` and reaps the turn as cancelled (rather than
|
|
256
|
+
* completed or failed). Suppressing the dispose error event would
|
|
257
|
+
* delete that signal and a turn that was actually torn down would
|
|
258
|
+
* record as successful in their telemetry pipeline. The original
|
|
259
|
+
* bug (issue #589 failure 2) was *untyped* error noise —
|
|
260
|
+
* `error.code` is already typed as `AgentSDKErrorType.DISPOSED`,
|
|
261
|
+
* which gives consumers a clean discriminator without
|
|
262
|
+
* string-matching.
|
|
263
|
+
*
|
|
264
|
+
* - **The SDK invariant requires every chat turn to end with one
|
|
265
|
+
* terminal `FinishEvent`.** `DefaultChatSession.wrapEventStream`
|
|
266
|
+
* only synthesizes a synthetic `finish` when an error landed on
|
|
267
|
+
* the stream; if dispose ended the sink without an error, the
|
|
268
|
+
* turn would end with neither a natural finish nor a synthetic
|
|
269
|
+
* one — breaking the SDK contract pinned in
|
|
270
|
+
* `packages/sfdx-agent-sdk/ARCHITECTURE.md` →
|
|
271
|
+
* "Critical Invariants" → "Every chat turn ends with exactly one
|
|
272
|
+
* terminal FinishEvent".
|
|
273
|
+
*
|
|
274
|
+
* Mirrors the Claude coordinator's `teardown` so the abort / dispose
|
|
275
|
+
* / timeout / pump-error paths can't drift out of step on either
|
|
276
|
+
* harness — issue #589 lands the same shape on both.
|
|
179
277
|
*/
|
|
180
278
|
teardown(reason, error, code) {
|
|
181
279
|
this.cancelAllTimers();
|
|
182
280
|
for (const id of this.emittedApprovals)
|
|
183
281
|
this.settledByCoordinator.add(id);
|
|
282
|
+
// Issue #589: drain in-flight consumer-tool ids into the
|
|
283
|
+
// idempotent-settle ledger so a late `submitToolResult` after
|
|
284
|
+
// teardown is a silent no-op rather than throwing
|
|
285
|
+
// `TOOL_CALL_NOT_FOUND`. Mirrors the approve/decline drain above.
|
|
286
|
+
for (const id of this.pendingByToolCallId.keys())
|
|
287
|
+
this.consumerToolSettled.add(id);
|
|
288
|
+
for (const id of this.toolCallArgs.keys())
|
|
289
|
+
this.consumerToolSettled.add(id);
|
|
184
290
|
// Reject every parked consumer-tool call so the bridge handler
|
|
185
291
|
// unblocks with an error rather than waiting forever.
|
|
186
292
|
for (const entry of this.pendingByToolCallId.values())
|
|
@@ -204,18 +310,9 @@ export class MastraApprovalCoordinator {
|
|
|
204
310
|
const resolve = this.continuationResolver;
|
|
205
311
|
this.continuationResolver = undefined;
|
|
206
312
|
resolve?.('done');
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
// end, so a `finish` event would be misleading.
|
|
211
|
-
this.initial.push({ type: 'error', error, code });
|
|
212
|
-
this.initial.end();
|
|
213
|
-
}
|
|
214
|
-
else {
|
|
215
|
-
this.initial.push({ type: 'error', error, code });
|
|
216
|
-
this.initial.push({ type: 'finish', finishReason: 'error' });
|
|
217
|
-
this.initial.end();
|
|
218
|
-
}
|
|
313
|
+
this.initial.push({ type: 'error', error, code });
|
|
314
|
+
this.initial.push({ type: 'finish', finishReason: 'error' });
|
|
315
|
+
this.initial.end();
|
|
219
316
|
this.pendingSuspendIds.clear();
|
|
220
317
|
this.runTeardownCallbacks();
|
|
221
318
|
}
|
|
@@ -227,14 +324,21 @@ export class MastraApprovalCoordinator {
|
|
|
227
324
|
this.recordDecision('decline', toolCallId, { kind: 'decline' });
|
|
228
325
|
}
|
|
229
326
|
recordDecision(method, toolCallId, decision) {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
327
|
+
// Issue #589: idempotent settle. A tool call the coordinator
|
|
328
|
+
// already auto-resolved (timeout / abort / dispose / pump-error)
|
|
329
|
+
// or the consumer already settled is a lost-race no-op rather
|
|
330
|
+
// than a throw — settle methods are void-typed, so the consumer
|
|
331
|
+
// never branches on a return value, and the outcome they wanted
|
|
332
|
+
// (id is settled) is already true.
|
|
333
|
+
if (this.settledByCoordinator.has(toolCallId) || this.consumerSettled.has(toolCallId)) {
|
|
334
|
+
return;
|
|
235
335
|
}
|
|
236
336
|
if (!this.emittedApprovals.has(toolCallId)) {
|
|
237
|
-
|
|
337
|
+
// Genuine misuse — the coordinator never saw this id (typo,
|
|
338
|
+
// wrong session, or settle called before approval-request /
|
|
339
|
+
// tool-call ever fired). Typed so consumers can branch on
|
|
340
|
+
// err.type instead of string-matching the message.
|
|
341
|
+
throw new AgentSDKError(`MastraApprovalCoordinator.${method}(): no pending approval for tool call "${toolCallId}"`, AgentSDKErrorType.TOOL_CALL_NOT_FOUND);
|
|
238
342
|
}
|
|
239
343
|
this.consumerSettled.add(toolCallId);
|
|
240
344
|
this.cancelTimer(toolCallId);
|
|
@@ -284,8 +388,18 @@ export class MastraApprovalCoordinator {
|
|
|
284
388
|
* resolves (probe-validated, see #529 step 2c).
|
|
285
389
|
*/
|
|
286
390
|
submitToolResult(toolCallId, result, isError) {
|
|
391
|
+
// Issue #589: idempotent settle. A submitToolResult for an id the
|
|
392
|
+
// consumer already settled, or that the coordinator drained at
|
|
393
|
+
// teardown time, is a silent no-op — the lost-race shape is
|
|
394
|
+
// unactionable from the consumer's side, and the method is
|
|
395
|
+
// void-typed. Mirrors the approve/decline path's
|
|
396
|
+
// `consumerSettled` + `settledByCoordinator` ledger.
|
|
397
|
+
if (this.consumerToolSettled.has(toolCallId)) {
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
287
400
|
const entry = this.pendingByToolCallId.get(toolCallId);
|
|
288
401
|
if (entry) {
|
|
402
|
+
this.consumerToolSettled.add(toolCallId);
|
|
289
403
|
this.pendingByToolCallId.delete(toolCallId);
|
|
290
404
|
entry.resolve({ result, isError });
|
|
291
405
|
return;
|
|
@@ -295,10 +409,14 @@ export class MastraApprovalCoordinator {
|
|
|
295
409
|
// We allow this whenever the coordinator has seen a `tool-call`
|
|
296
410
|
// event under that id (reachable in `toolCallArgs`); else throw.
|
|
297
411
|
if (this.toolCallArgs.has(toolCallId)) {
|
|
412
|
+
this.consumerToolSettled.add(toolCallId);
|
|
298
413
|
this.preSubmittedByToolCallId.set(toolCallId, { result, isError });
|
|
299
414
|
return;
|
|
300
415
|
}
|
|
301
|
-
|
|
416
|
+
// Issue #589: typed error — the coordinator never saw this id, so
|
|
417
|
+
// this is genuine misuse rather than the lost-race shape the
|
|
418
|
+
// idempotent-settle path collapses to a no-op.
|
|
419
|
+
throw new AgentSDKError(`MastraApprovalCoordinator.submitToolResult(): no pending tool call for id "${toolCallId}"`, AgentSDKErrorType.TOOL_CALL_NOT_FOUND);
|
|
302
420
|
}
|
|
303
421
|
// ───────────────────────── pump loop ────────────────────────────────────
|
|
304
422
|
/**
|
|
@@ -548,6 +666,21 @@ export class MastraApprovalCoordinator {
|
|
|
548
666
|
if (this.aborted || this.disposed)
|
|
549
667
|
return;
|
|
550
668
|
const enriched = this.maybeEnrich(event);
|
|
669
|
+
// Issue #591: track in-flight MCP tool call so progress notifications
|
|
670
|
+
// arriving via `progress.onUpdate(...)` can be attributed to the
|
|
671
|
+
// right `toolCallId`. Set on enriched (i.e. MCP-namespaced) tool-call;
|
|
672
|
+
// cleared on the matching tool-result. Non-MCP tools don't carry
|
|
673
|
+
// `serverName` (the lookup returns undefined) so this never fires
|
|
674
|
+
// for built-ins, consumer-declared tools, or workspace tools.
|
|
675
|
+
if (enriched.type === 'tool-call' && enriched.serverName !== undefined) {
|
|
676
|
+
this.currentMcpToolCallId = enriched.toolCallId;
|
|
677
|
+
this.toolNameForCallId.set(enriched.toolCallId, enriched.toolName);
|
|
678
|
+
}
|
|
679
|
+
else if (enriched.type === 'tool-result' &&
|
|
680
|
+
enriched.serverName !== undefined &&
|
|
681
|
+
this.currentMcpToolCallId === enriched.toolCallId) {
|
|
682
|
+
this.currentMcpToolCallId = null;
|
|
683
|
+
}
|
|
551
684
|
this.initial.push(enriched);
|
|
552
685
|
}
|
|
553
686
|
maybeEnrich(event) {
|
|
@@ -33,6 +33,8 @@ import type { ChatEvent } from '@salesforce/sfdx-agent-sdk';
|
|
|
33
33
|
* | `tool-call-approval` | `tool-approval-request` | `{ toolCall: { toolCallId, toolName, args } }` — stream suspends |
|
|
34
34
|
* | `tool-result` | `tool-result` | `{ toolCallId, toolName, result, isError: false }` |
|
|
35
35
|
* | `tool-error` | `tool-result` | `{ toolCallId, toolName, result: error, isError: true}`|
|
|
36
|
+
* | `tool-output` | `tool-progress` | `{ toolCallId, toolName, output }` |
|
|
37
|
+
* | `tool-call-delta` | `tool-call-delta` | `{ toolCallId, toolName?, argsTextDelta }` |
|
|
36
38
|
* | `step-start` | `step-start` | `{ stepIndex }` from step metadata |
|
|
37
39
|
* | `step-finish` | `step-finish` | `{ stepIndex, finishReason, usage }` from `stepResult` |
|
|
38
40
|
* | `finish` | `finish` | `{ finishReason, usage }` from `output` |
|
|
@@ -44,9 +46,8 @@ import type { ChatEvent } from '@salesforce/sfdx-agent-sdk';
|
|
|
44
46
|
* | `reasoning-start`, | | |
|
|
45
47
|
* | `reasoning-end` | | |
|
|
46
48
|
* | `source`, `file`, | *(skipped)* | Known but unsupported — future ChatEvent candidates |
|
|
47
|
-
* | `tool-call-delta`, | | |
|
|
48
49
|
* | `tool-call-suspended`, | | |
|
|
49
|
-
* | `object
|
|
50
|
+
* | `object` | | |
|
|
50
51
|
* | *(all others)* | *(skipped)* | Logged via `LogBus.debug` with `{ chunkType, rawChunk }` |
|
|
51
52
|
*
|
|
52
53
|
* Stateful per stream — a new instance must be created for each `MastraHarness.stream()` call.
|
|
@@ -34,6 +34,8 @@
|
|
|
34
34
|
* | `tool-call-approval` | `tool-approval-request` | `{ toolCall: { toolCallId, toolName, args } }` — stream suspends |
|
|
35
35
|
* | `tool-result` | `tool-result` | `{ toolCallId, toolName, result, isError: false }` |
|
|
36
36
|
* | `tool-error` | `tool-result` | `{ toolCallId, toolName, result: error, isError: true}`|
|
|
37
|
+
* | `tool-output` | `tool-progress` | `{ toolCallId, toolName, output }` |
|
|
38
|
+
* | `tool-call-delta` | `tool-call-delta` | `{ toolCallId, toolName?, argsTextDelta }` |
|
|
37
39
|
* | `step-start` | `step-start` | `{ stepIndex }` from step metadata |
|
|
38
40
|
* | `step-finish` | `step-finish` | `{ stepIndex, finishReason, usage }` from `stepResult` |
|
|
39
41
|
* | `finish` | `finish` | `{ finishReason, usage }` from `output` |
|
|
@@ -45,9 +47,8 @@
|
|
|
45
47
|
* | `reasoning-start`, | | |
|
|
46
48
|
* | `reasoning-end` | | |
|
|
47
49
|
* | `source`, `file`, | *(skipped)* | Known but unsupported — future ChatEvent candidates |
|
|
48
|
-
* | `tool-call-delta`, | | |
|
|
49
50
|
* | `tool-call-suspended`, | | |
|
|
50
|
-
* | `object
|
|
51
|
+
* | `object` | | |
|
|
51
52
|
* | *(all others)* | *(skipped)* | Logged via `LogBus.debug` with `{ chunkType, rawChunk }` |
|
|
52
53
|
*
|
|
53
54
|
* Stateful per stream — a new instance must be created for each `MastraHarness.stream()` call.
|
|
@@ -169,19 +170,35 @@ export class MastraEventAdapter {
|
|
|
169
170
|
case 'reasoning-end':
|
|
170
171
|
this.logBus?.debug('Skipping informational Mastra chunk', { chunkType: chunk.type });
|
|
171
172
|
return undefined;
|
|
173
|
+
case 'tool-output':
|
|
174
|
+
return {
|
|
175
|
+
type: 'tool-progress',
|
|
176
|
+
toolCallId: chunk.payload.toolCallId,
|
|
177
|
+
toolName: chunk.payload.toolName ?? 'unknown',
|
|
178
|
+
output: chunk.payload.output,
|
|
179
|
+
};
|
|
180
|
+
case 'tool-call-delta':
|
|
181
|
+
return {
|
|
182
|
+
type: 'tool-call-delta',
|
|
183
|
+
toolCallId: chunk.payload.toolCallId,
|
|
184
|
+
// Strict `!== undefined` (not truthy) so an empty-string
|
|
185
|
+
// toolName from upstream — unlikely but possible — is
|
|
186
|
+
// forwarded honestly instead of silently swallowed.
|
|
187
|
+
...(chunk.payload.toolName !== undefined ? { toolName: chunk.payload.toolName } : {}),
|
|
188
|
+
argsTextDelta: chunk.payload.argsTextDelta,
|
|
189
|
+
};
|
|
172
190
|
// Known harness chunk types not yet supported in the SDK's ChatEvent
|
|
173
191
|
// model. Silently dropped to avoid leaking harness internals onto the
|
|
174
192
|
// public stream. Candidates for future ChatEvent variants when the
|
|
175
|
-
// corresponding features (citations, file generation, tool
|
|
176
|
-
//
|
|
193
|
+
// corresponding features (citations, file generation, tool input
|
|
194
|
+
// lifecycle bookends, tool suspension, structured output) are
|
|
195
|
+
// supported.
|
|
177
196
|
case 'source':
|
|
178
197
|
case 'file':
|
|
179
|
-
case 'tool-call-delta':
|
|
180
198
|
case 'tool-call-input-streaming-start':
|
|
181
199
|
case 'tool-call-input-streaming-end':
|
|
182
200
|
case 'tool-call-suspended':
|
|
183
201
|
case 'object':
|
|
184
|
-
case 'tool-output':
|
|
185
202
|
this.logBus?.debug('Skipping unsupported Mastra chunk type', { chunkType: chunk.type });
|
|
186
203
|
return undefined;
|
|
187
204
|
default:
|
package/dist/mastra-harness.d.ts
CHANGED
|
@@ -478,6 +478,22 @@ export declare class MastraHarness implements MastraAgentHarness {
|
|
|
478
478
|
* after `shutdown()` has disposed the buses.
|
|
479
479
|
*/
|
|
480
480
|
private prepareMcpStatusChange;
|
|
481
|
+
/**
|
|
482
|
+
* Issue #591: install a `progress.onUpdate(serverName, ...)` listener
|
|
483
|
+
* on the MCP client. Incoming `notifications/progress` are dispatched
|
|
484
|
+
* to the active coordinator on this agent whose `currentRunId` matches
|
|
485
|
+
* the notification's `progressToken` — `enableProgressTracking: true`
|
|
486
|
+
* makes `@mastra/mcp` inject `_meta.progressToken = context.runId` into
|
|
487
|
+
* every tool call, so the token uniquely identifies the originating
|
|
488
|
+
* turn even across concurrent threads on the same agent.
|
|
489
|
+
*
|
|
490
|
+
* `progress.onUpdate` is async (it lazily connects the underlying
|
|
491
|
+
* internal client). We swallow rejections inside a fire-and-forget
|
|
492
|
+
* wrapper rather than blocking `createAgent`/`updateAgent` — discovery
|
|
493
|
+
* and progress tracking are independent: a server that fails to wire
|
|
494
|
+
* progress still works for tool calls, just without progress events.
|
|
495
|
+
*/
|
|
496
|
+
private registerMcpProgressListener;
|
|
481
497
|
/**
|
|
482
498
|
* Runs MCP discovery for one server: emits the
|
|
483
499
|
* `mcp-server-discovery-started` / `…-completed` / `…-failed` telemetry
|
package/dist/mastra-harness.js
CHANGED
|
@@ -473,6 +473,9 @@ export class MastraHarness {
|
|
|
473
473
|
};
|
|
474
474
|
mcpServerStates.set(serverName, entry);
|
|
475
475
|
entry.ready = this.runMcpDiscovery(agentId, serverName, client, mcpServerStates, 0, options?.abortSignal);
|
|
476
|
+
// Issue #591: bridge MCP `notifications/progress` onto the
|
|
477
|
+
// coordinator's per-turn sink as `tool-progress` ChatEvents.
|
|
478
|
+
this.registerMcpProgressListener(agentId, serverName, client);
|
|
476
479
|
}
|
|
477
480
|
}
|
|
478
481
|
// Issue #529 step 2c: the bridge `execute` handler resolves the
|
|
@@ -893,6 +896,8 @@ export class MastraHarness {
|
|
|
893
896
|
};
|
|
894
897
|
state.mcpServers.set(name, entry);
|
|
895
898
|
entry.ready = this.runMcpDiscovery(agentId, name, client, state.mcpServers, 0, options?.abortSignal);
|
|
899
|
+
// Issue #591: same MCP-progress wiring as createAgent path.
|
|
900
|
+
this.registerMcpProgressListener(agentId, name, client);
|
|
896
901
|
}
|
|
897
902
|
}
|
|
898
903
|
}
|
|
@@ -1528,6 +1533,52 @@ export class MastraHarness {
|
|
|
1528
1533
|
}
|
|
1529
1534
|
};
|
|
1530
1535
|
}
|
|
1536
|
+
/**
|
|
1537
|
+
* Issue #591: install a `progress.onUpdate(serverName, ...)` listener
|
|
1538
|
+
* on the MCP client. Incoming `notifications/progress` are dispatched
|
|
1539
|
+
* to the active coordinator on this agent whose `currentRunId` matches
|
|
1540
|
+
* the notification's `progressToken` — `enableProgressTracking: true`
|
|
1541
|
+
* makes `@mastra/mcp` inject `_meta.progressToken = context.runId` into
|
|
1542
|
+
* every tool call, so the token uniquely identifies the originating
|
|
1543
|
+
* turn even across concurrent threads on the same agent.
|
|
1544
|
+
*
|
|
1545
|
+
* `progress.onUpdate` is async (it lazily connects the underlying
|
|
1546
|
+
* internal client). We swallow rejections inside a fire-and-forget
|
|
1547
|
+
* wrapper rather than blocking `createAgent`/`updateAgent` — discovery
|
|
1548
|
+
* and progress tracking are independent: a server that fails to wire
|
|
1549
|
+
* progress still works for tool calls, just without progress events.
|
|
1550
|
+
*/
|
|
1551
|
+
registerMcpProgressListener(agentId, serverName, client) {
|
|
1552
|
+
client.progress
|
|
1553
|
+
.onUpdate(serverName, (params) => {
|
|
1554
|
+
const agentState = this.activeAgents.get(agentId);
|
|
1555
|
+
if (!agentState)
|
|
1556
|
+
return;
|
|
1557
|
+
// Walk per-thread coordinators to find the one whose current
|
|
1558
|
+
// run produced this progressToken. Mastra's
|
|
1559
|
+
// `enableProgressTracking` injects `progressToken =
|
|
1560
|
+
// context.runId`, so `progressToken === coordinator.runId`
|
|
1561
|
+
// is an exact, single-match filter.
|
|
1562
|
+
const targetToken = String(params.progressToken);
|
|
1563
|
+
for (const coordinator of agentState.coordinators.values()) {
|
|
1564
|
+
if (coordinator.getCurrentRunId() === targetToken) {
|
|
1565
|
+
coordinator.emitMcpProgress({
|
|
1566
|
+
progress: params.progress,
|
|
1567
|
+
...(params.total !== undefined && { total: params.total }),
|
|
1568
|
+
...(params.message !== undefined && { message: params.message }),
|
|
1569
|
+
});
|
|
1570
|
+
return;
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
})
|
|
1574
|
+
.catch((err) => {
|
|
1575
|
+
this.buses.getLogBus()?.warn('Failed to register MCP progress listener', {
|
|
1576
|
+
agentId,
|
|
1577
|
+
serverName,
|
|
1578
|
+
error: getErrorMessage(err),
|
|
1579
|
+
});
|
|
1580
|
+
});
|
|
1581
|
+
}
|
|
1531
1582
|
/**
|
|
1532
1583
|
* Runs MCP discovery for one server: emits the
|
|
1533
1584
|
* `mcp-server-discovery-started` / `…-completed` / `…-failed` telemetry
|
|
@@ -56,6 +56,13 @@ export function mapToMastraMCPServers(config, orgJwt) {
|
|
|
56
56
|
...(serverConfig.args !== undefined && { args: serverConfig.args }),
|
|
57
57
|
...(serverConfig.env !== undefined && { env: serverConfig.env }),
|
|
58
58
|
...(serverConfig.timeout !== undefined && { timeout: serverConfig.timeout }),
|
|
59
|
+
// Issue #591: opt every server in to MCP progress notifications
|
|
60
|
+
// so the upstream server knows it can send `notifications/progress`
|
|
61
|
+
// (the harness bridges those to `tool-progress` ChatEvents via
|
|
62
|
+
// `mcpClient.progress.onUpdate(...)`). Default in `@mastra/mcp` is
|
|
63
|
+
// off, so without this the spec-compliant progressToken is never
|
|
64
|
+
// injected into `_meta` and servers send no progress.
|
|
65
|
+
enableProgressTracking: true,
|
|
59
66
|
};
|
|
60
67
|
result[name] = def;
|
|
61
68
|
}
|
|
@@ -77,6 +84,8 @@ export function mapToMastraMCPServers(config, orgJwt) {
|
|
|
77
84
|
...serverConfig.reconnectionOptions,
|
|
78
85
|
},
|
|
79
86
|
}),
|
|
87
|
+
// Issue #591: same rationale as stdio.
|
|
88
|
+
enableProgressTracking: true,
|
|
80
89
|
};
|
|
81
90
|
result[name] = def;
|
|
82
91
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@salesforce/sfdx-agent-harness-mastra",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "Mastra-backed AgentHarness implementation for @salesforce/sfdx-agent-sdk",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -38,11 +38,11 @@
|
|
|
38
38
|
"@salesforce/llm-gateway-sdk": "0.14.0"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
|
-
"@salesforce/sfdx-agent-sdk": "0.
|
|
41
|
+
"@salesforce/sfdx-agent-sdk": "0.21.0"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@eslint/js": "^10.0.1",
|
|
45
|
-
"@salesforce/sfdx-agent-sdk": "0.
|
|
45
|
+
"@salesforce/sfdx-agent-sdk": "0.21.0",
|
|
46
46
|
"@types/node": "^22.19.20",
|
|
47
47
|
"@vitest/coverage-istanbul": "^4.1.8",
|
|
48
48
|
"@vitest/eslint-plugin": "^1.6.19",
|