@salesforce/sfdx-agent-harness-mastra 0.80.0 → 0.82.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,15 @@
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.82.0] - 2026-09-16
7
+
8
+ ### Features
9
+ - **harness-mastra**: stream tool-call argument deltas from pass-through builders @W-23954558@ ([#824](https://github.com/forcedotcom/agentic-dx/pull/824))
10
+
11
+ ## [0.81.0] - 2026-09-15
12
+
13
+ _No changes — released alongside dependent packages._
14
+
6
15
  ## [0.80.0] - 2026-09-15
7
16
 
8
17
  ### Chores
@@ -5,7 +5,7 @@
5
5
  import { AnthropicBedrock } from '@anthropic-ai/bedrock-sdk';
6
6
  import Anthropic from '@anthropic-ai/sdk';
7
7
  import { EmptyStreamError, NetworkStreamError, isStreamTransportError, recognizeBedrockValidationError, } from './stream-errors.js';
8
- import { TextSpanLifecycle, buildValidatedToolCallChunk, createGatewayFetch, isEmptyUsage, isParameterExcluded, mapAnthropicFinishReason, mapAnthropicUsage, toolResultOutputToString, } from './v3-stream-mapping.js';
8
+ import { TextSpanLifecycle, ToolInputSpanLifecycle, buildValidatedToolCallChunk, createGatewayFetch, isEmptyUsage, isParameterExcluded, mapAnthropicFinishReason, mapAnthropicUsage, toolResultOutputToString, } from './v3-stream-mapping.js';
9
9
  /**
10
10
  * Builds the in-process `LanguageModelV3` impl for the Salesforce LLM Gateway's
11
11
  * `/invoke-with-response-stream` (Bedrock-Anthropic pass-through) endpoint.
@@ -357,10 +357,14 @@ async function doStream(ctx, options) {
357
357
  // A tool_use block follows any open text — close that span and
358
358
  // bump the id so post-tool text lands in a fresh span.
359
359
  textSpan.rotate(controller);
360
+ // Stream incremental tool-call arg deltas alongside the
361
+ // unchanged terminal `tool-call` (W-23954558); see
362
+ // ToolInputSpanLifecycle.
360
363
  toolUseByIndex.set(event.index, {
361
364
  id: block.id,
362
365
  name: block.name,
363
366
  argsBuffer: '',
367
+ input: new ToolInputSpanLifecycle(block.id, block.name, ctx.logBus),
364
368
  });
365
369
  }
366
370
  // Other block types (thinking, server-tool result, etc.) are
@@ -375,8 +379,10 @@ async function doStream(ctx, options) {
375
379
  }
376
380
  else if (delta.type === 'input_json_delta') {
377
381
  const tu = toolUseByIndex.get(event.index);
378
- if (tu)
382
+ if (tu) {
379
383
  tu.argsBuffer += delta.partial_json;
384
+ tu.input.delta(controller, delta.partial_json);
385
+ }
380
386
  }
381
387
  // thinking_delta / signature_delta / citations_delta are not
382
388
  // currently surfaced — Mastra has no V3 stream-part for them in
@@ -388,6 +394,9 @@ async function doStream(ctx, options) {
388
394
  if (tu) {
389
395
  sawToolCalls = true;
390
396
  sawContentOrUsage = true;
397
+ // Close the tool-input span before the unchanged terminal
398
+ // `tool-call` (wire order start → delta* → end → call).
399
+ tu.input.close(controller, tu.argsBuffer);
391
400
  for (const part of buildValidatedToolCallChunk(tu.id, tu.name, tu.argsBuffer, toolSchemas.get(tu.name), ctx.getConsumerToolNames().has(tu.name), { retryCounts: ctx.getToolArgsRetryCounts(options), logBus: ctx.logBus })) {
392
401
  controller.enqueue(part);
393
402
  }
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import OpenAI from 'openai';
6
6
  import { EmptyStreamError } from './stream-errors.js';
7
- import { TextSpanLifecycle, buildValidatedToolCallChunk, createGatewayFetch, isEmptyUsage, isParameterExcluded, mapOpenAIUsage, toolResultOutputToString, } from './v3-stream-mapping.js';
7
+ import { TextSpanLifecycle, ToolInputSpanLifecycle, buildValidatedToolCallChunk, createGatewayFetch, isEmptyUsage, isParameterExcluded, mapOpenAIUsage, toolResultOutputToString, } from './v3-stream-mapping.js';
8
8
  /**
9
9
  * Builds the in-process `LanguageModelV3` impl that drives the `openai` SDK's
10
10
  * `chat.completions.create({ stream: true })` against an OpenAI-compatible
@@ -202,6 +202,28 @@ async function doStream(ctx, options) {
202
202
  if (tc.function?.arguments)
203
203
  acc.argsBuffer += tc.function.arguments;
204
204
  }
205
+ // Stream incremental tool-call arg deltas alongside the
206
+ // unchanged terminal `tool-call` (W-23954558; see
207
+ // ToolInputSpanLifecycle). Chat Completions has no discrete
208
+ // start/done event, so the span opens lazily once an `id` is
209
+ // known (OpenAI-compatible servers send it on the first delta)
210
+ // and closes at the flush.
211
+ if (!acc.input) {
212
+ if (acc.id) {
213
+ acc.input = new ToolInputSpanLifecycle(acc.id, acc.name, ctx.logBus);
214
+ // Stream whatever accrued before the id was known
215
+ // (normally just this chunk's fragment) so the deltas
216
+ // still concatenate to the terminal input.
217
+ acc.input.delta(controller, acc.argsBuffer);
218
+ }
219
+ // No id yet: args stay buffered; the catch-up delta above
220
+ // flushes them once the id arrives (OpenAI-compatible servers
221
+ // send `id` on the first delta, so this only defers the
222
+ // pathological id-less-first-delta case).
223
+ }
224
+ else if (tc.function?.arguments) {
225
+ acc.input.delta(controller, tc.function.arguments);
226
+ }
205
227
  }
206
228
  }
207
229
  if (choice.finish_reason !== null && choice.finish_reason !== undefined) {
@@ -232,6 +254,10 @@ async function doStream(ctx, options) {
232
254
  continue;
233
255
  }
234
256
  sawToolCalls = true;
257
+ // Close the tool-input span before the unchanged terminal
258
+ // `tool-call`. `acc.id` is non-empty here (the guard above
259
+ // `continue`s otherwise), so `acc.input` was constructed.
260
+ acc.input?.close(controller, acc.argsBuffer);
235
261
  for (const part of buildValidatedToolCallChunk(acc.id, acc.name, acc.argsBuffer, toolSchemas.get(acc.name), ctx.getConsumerToolNames().has(acc.name), { retryCounts: ctx.getToolArgsRetryCounts(options), logBus: ctx.logBus })) {
236
262
  controller.enqueue(part);
237
263
  }
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import OpenAI from 'openai';
6
6
  import { EmptyStreamError, GatewayResponseError } from './stream-errors.js';
7
- import { TextSpanLifecycle, buildValidatedToolCallChunk, createGatewayFetch, isEmptyUsage, isParameterExcluded, mapOpenAIFinishReason, mapOpenAIUsage, toolResultOutputToString, } from './v3-stream-mapping.js';
7
+ import { TextSpanLifecycle, ToolInputSpanLifecycle, buildValidatedToolCallChunk, createGatewayFetch, isEmptyUsage, isParameterExcluded, mapOpenAIFinishReason, mapOpenAIUsage, toolResultOutputToString, } from './v3-stream-mapping.js';
8
8
  /**
9
9
  * Builds the in-process `LanguageModelV3` impl that drives the `openai` SDK's
10
10
  * `responses.create({ stream: true })` against the Salesforce LLM Gateway's
@@ -148,18 +148,30 @@ async function doStream(ctx, options) {
148
148
  // A tool_call follows any open text — close that span and bump
149
149
  // the id so post-tool text lands in a fresh span.
150
150
  textSpan.rotate(controller);
151
+ const argsBuffer = item.arguments ?? '';
152
+ // Stream incremental tool-call arg deltas alongside the
153
+ // unchanged terminal `tool-call` (W-23954558); see
154
+ // ToolInputSpanLifecycle.
155
+ const input = new ToolInputSpanLifecycle(item.call_id, item.name, ctx.logBus);
151
156
  toolCallById.set(item.id ?? item.call_id, {
152
157
  id: item.call_id,
153
158
  name: item.name,
154
- argsBuffer: item.arguments ?? '',
159
+ argsBuffer,
160
+ input,
155
161
  });
162
+ // Responses streams args via function_call_arguments.delta,
163
+ // so `arguments` is normally empty here; forward any
164
+ // pre-populated buffer as a fragment.
165
+ input.delta(controller, argsBuffer);
156
166
  }
157
167
  break;
158
168
  }
159
169
  case 'response.function_call_arguments.delta': {
160
170
  const tc = toolCallById.get(event.item_id);
161
- if (tc)
171
+ if (tc) {
162
172
  tc.argsBuffer += event.delta;
173
+ tc.input.delta(controller, event.delta);
174
+ }
163
175
  break;
164
176
  }
165
177
  case 'response.output_item.done': {
@@ -169,6 +181,10 @@ async function doStream(ctx, options) {
169
181
  const finalArgs = item.arguments ?? tc?.argsBuffer ?? '';
170
182
  sawToolCalls = true;
171
183
  sawContent = true;
184
+ // Close the tool-input span before the unchanged terminal
185
+ // `tool-call` (handles the atomic case where args arrive
186
+ // only here, with no incremental deltas).
187
+ tc?.input.close(controller, finalArgs);
172
188
  for (const part of buildValidatedToolCallChunk(item.call_id, item.name, finalArgs, toolSchemas.get(item.name), ctx.getConsumerToolNames().has(item.name), { retryCounts: ctx.getToolArgsRetryCounts(options), logBus: ctx.logBus })) {
173
189
  controller.enqueue(part);
174
190
  }
@@ -63,6 +63,55 @@ export declare class TextSpanLifecycle {
63
63
  }): void;
64
64
  private spanId;
65
65
  }
66
+ /**
67
+ * Encodes the AI SDK V3 tool-input streaming lifecycle (`tool-input-start` /
68
+ * `tool-input-delta` / `tool-input-end`) that surfaces incremental tool-call
69
+ * argument fragments to consumers (W-23954558). One instance per tool call.
70
+ *
71
+ * These parts are emitted IN ADDITION TO — never instead of — the authoritative
72
+ * terminal `tool-call` part (built by {@link buildValidatedToolCallChunk}), in
73
+ * real wire order: `start → delta* → end → tool-call`.
74
+ *
75
+ * Load-bearing invariant: `@mastra/core`'s stream pipeline, once it sees a
76
+ * `tool-input-start`, derives the final tool call's `args` from the ACCUMULATED
77
+ * `tool-input-delta` fragments — NOT from the terminal `tool-call.input`. So the
78
+ * concatenation of the deltas this class emits MUST reproduce the argument
79
+ * string the terminal `tool-call` carries, or Mastra surfaces truncated/empty
80
+ * args. This class enforces that:
81
+ *
82
+ * - The span is opened LAZILY on the first non-empty fragment, so a genuinely
83
+ * no-argument call (zero fragments) never opens a span and Mastra falls back
84
+ * to the terminal `input` (`'{}'`). An empty fragment is dropped (Mastra drops
85
+ * a zero-length `tool-call-delta` anyway).
86
+ * - {@link close} reconciles before the terminal part: if fragments streamed
87
+ * incrementally, it emits any un-streamed tail of `finalArgs`; if the args
88
+ * arrived atomically (some providers deliver the complete args only on the
89
+ * terminal event, with no incremental deltas — e.g. the OpenAI Responses
90
+ * `output_item.done`), it opens the span and streams the whole buffer as one
91
+ * delta so the accumulator still matches. Either way the emitted deltas
92
+ * concatenate to exactly `finalArgs`.
93
+ */
94
+ export declare class ToolInputSpanLifecycle {
95
+ private readonly id;
96
+ private readonly toolName;
97
+ private readonly logBus?;
98
+ private started;
99
+ private streamed;
100
+ constructor(id: string, toolName: string, logBus?: LogBus | undefined);
101
+ /** Forward one raw argument fragment as a `tool-input-delta`, opening the span on first use. Empty fragments are dropped. */
102
+ delta(controller: {
103
+ enqueue: (part: LanguageModelV3StreamPart) => void;
104
+ }, fragment: string): void;
105
+ /**
106
+ * Reconcile the streamed deltas against the authoritative `finalArgs` (the
107
+ * exact string handed to the terminal `tool-call`) and close the span. Call
108
+ * immediately BEFORE emitting the terminal `tool-call`. No-op emission for a
109
+ * never-opened, empty-args span so a no-arg call stays span-free.
110
+ */
111
+ close(controller: {
112
+ enqueue: (part: LanguageModelV3StreamPart) => void;
113
+ }, finalArgs: string): void;
114
+ }
66
115
  /**
67
116
  * Maps a Salesforce LLM Gateway raw finish-reason string (the gateway-normalized
68
117
  * vocabulary that spans GPT-5 and Claude — `stop`, `length`, `tool_calls`,
@@ -81,6 +81,92 @@ export class TextSpanLifecycle {
81
81
  return `text-${this.id}`;
82
82
  }
83
83
  }
84
+ /**
85
+ * Encodes the AI SDK V3 tool-input streaming lifecycle (`tool-input-start` /
86
+ * `tool-input-delta` / `tool-input-end`) that surfaces incremental tool-call
87
+ * argument fragments to consumers (W-23954558). One instance per tool call.
88
+ *
89
+ * These parts are emitted IN ADDITION TO — never instead of — the authoritative
90
+ * terminal `tool-call` part (built by {@link buildValidatedToolCallChunk}), in
91
+ * real wire order: `start → delta* → end → tool-call`.
92
+ *
93
+ * Load-bearing invariant: `@mastra/core`'s stream pipeline, once it sees a
94
+ * `tool-input-start`, derives the final tool call's `args` from the ACCUMULATED
95
+ * `tool-input-delta` fragments — NOT from the terminal `tool-call.input`. So the
96
+ * concatenation of the deltas this class emits MUST reproduce the argument
97
+ * string the terminal `tool-call` carries, or Mastra surfaces truncated/empty
98
+ * args. This class enforces that:
99
+ *
100
+ * - The span is opened LAZILY on the first non-empty fragment, so a genuinely
101
+ * no-argument call (zero fragments) never opens a span and Mastra falls back
102
+ * to the terminal `input` (`'{}'`). An empty fragment is dropped (Mastra drops
103
+ * a zero-length `tool-call-delta` anyway).
104
+ * - {@link close} reconciles before the terminal part: if fragments streamed
105
+ * incrementally, it emits any un-streamed tail of `finalArgs`; if the args
106
+ * arrived atomically (some providers deliver the complete args only on the
107
+ * terminal event, with no incremental deltas — e.g. the OpenAI Responses
108
+ * `output_item.done`), it opens the span and streams the whole buffer as one
109
+ * delta so the accumulator still matches. Either way the emitted deltas
110
+ * concatenate to exactly `finalArgs`.
111
+ */
112
+ export class ToolInputSpanLifecycle {
113
+ id;
114
+ toolName;
115
+ logBus;
116
+ started = false;
117
+ streamed = '';
118
+ constructor(id, toolName, logBus) {
119
+ this.id = id;
120
+ this.toolName = toolName;
121
+ this.logBus = logBus;
122
+ }
123
+ /** Forward one raw argument fragment as a `tool-input-delta`, opening the span on first use. Empty fragments are dropped. */
124
+ delta(controller, fragment) {
125
+ if (fragment.length === 0)
126
+ return;
127
+ if (!this.started) {
128
+ controller.enqueue({ type: 'tool-input-start', id: this.id, toolName: this.toolName });
129
+ this.started = true;
130
+ }
131
+ controller.enqueue({ type: 'tool-input-delta', id: this.id, delta: fragment });
132
+ this.streamed += fragment;
133
+ }
134
+ /**
135
+ * Reconcile the streamed deltas against the authoritative `finalArgs` (the
136
+ * exact string handed to the terminal `tool-call`) and close the span. Call
137
+ * immediately BEFORE emitting the terminal `tool-call`. No-op emission for a
138
+ * never-opened, empty-args span so a no-arg call stays span-free.
139
+ */
140
+ close(controller, finalArgs) {
141
+ if (!this.started) {
142
+ // Atomic-args case: the provider delivered the complete args only on the
143
+ // terminal event (no incremental fragments). Stream the whole buffer once
144
+ // so Mastra's accumulator reproduces the args. A truly empty buffer leaves
145
+ // the span unopened and Mastra uses the terminal `input`.
146
+ this.delta(controller, finalArgs);
147
+ }
148
+ else if (this.streamed !== finalArgs) {
149
+ if (finalArgs.startsWith(this.streamed)) {
150
+ // Incremental fragments under-covered the final buffer (e.g. a terminal
151
+ // event carried a more-complete `arguments` than the streamed deltas).
152
+ // Emit the un-streamed tail so the accumulator still equals `finalArgs`.
153
+ this.delta(controller, finalArgs.slice(this.streamed.length));
154
+ }
155
+ else {
156
+ // Genuine divergence: the streamed deltas are NOT a prefix of the terminal
157
+ // args (a provider that re-normalized the JSON on the terminal event). We
158
+ // can't un-emit, so the delta-derived args (Mastra accumulator, AG-UI
159
+ // TOOL_CALL_ARGS) will be wrong with no terminal ARGS to correct them.
160
+ // Real providers stream prefixes, so this is unreachable in practice —
161
+ // field a signal so a future provider quirk surfaces instead of silently
162
+ // corrupting args. Lengths only (args may carry PII).
163
+ this.logBus?.warn('Tool-call arg deltas diverged from the terminal args; streamed deltas do not reconstruct the full arguments downstream', { toolName: this.toolName, streamedLength: this.streamed.length, finalLength: finalArgs.length });
164
+ }
165
+ }
166
+ if (this.started)
167
+ controller.enqueue({ type: 'tool-input-end', id: this.id });
168
+ }
169
+ }
84
170
  /**
85
171
  * Maps a Salesforce LLM Gateway raw finish-reason string (the gateway-normalized
86
172
  * vocabulary that spans GPT-5 and Claude — `stop`, `length`, `tool_calls`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/sfdx-agent-harness-mastra",
3
- "version": "0.80.0",
3
+ "version": "0.82.0",
4
4
  "description": "Mastra-backed AgentHarness implementation for @salesforce/sfdx-agent-sdk",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -42,12 +42,12 @@
42
42
  "openai": "^7.0.0"
43
43
  },
44
44
  "peerDependencies": {
45
- "@salesforce/sfdx-agent-sdk": "0.81.0"
45
+ "@salesforce/sfdx-agent-sdk": "0.83.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@eslint/js": "^10.0.1",
49
49
  "@salesforce/harness-conformance": "0.0.1",
50
- "@salesforce/sfdx-agent-sdk": "0.81.0",
50
+ "@salesforce/sfdx-agent-sdk": "0.83.0",
51
51
  "@types/node": "^22.20.1",
52
52
  "@vitest/coverage-istanbul": "^4.1.11",
53
53
  "@vitest/eslint-plugin": "^1.6.27",