@salesforce/sfdx-agent-harness-mastra 0.71.0 → 0.73.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,18 @@
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.73.0] - 2026-09-08
7
+
8
+ ### Chores
9
+ - **deps**: bump @anthropic-ai/sdk from 0.115.0 to 0.122.0 ([#797](https://github.com/forcedotcom/agentic-dx/pull/797))
10
+ - **deps**: bump the mastra group across 1 directory with 3 updates ([#796](https://github.com/forcedotcom/agentic-dx/pull/796))
11
+ - **deps-dev**: bump the dev-dependencies group with 3 updates ([#795](https://github.com/forcedotcom/agentic-dx/pull/795))
12
+
13
+ ## [0.72.0] - 2026-09-04
14
+
15
+ ### Features
16
+ - **agent-sdk,harness-claude,harness-mastra,harness-openai**: validate frontend tool-call args @W-24023028@ ([#789](https://github.com/forcedotcom/agentic-dx/pull/789))
17
+
6
18
  ## [0.71.0] - 2026-09-02
7
19
 
8
20
  _No changes — released alongside dependent packages._
@@ -31,7 +31,7 @@ import type { ChatEvent } from '@salesforce/sfdx-agent-sdk';
31
31
  * | `reasoning-delta` | `reasoning-delta` | `{ text }` from `payload.text` |
32
32
  * | `tool-call` | `tool-call` | `{ toolCallId, toolName, args }` |
33
33
  * | `tool-call-approval` | `tool-approval-request` | `{ toolCall: { toolCallId, toolName, args } }` — stream suspends |
34
- * | `tool-result` | `tool-result` | `{ toolCallId, toolName, result, isError: false }` |
34
+ * | `tool-result` | `tool-result` | `{ toolCallId, toolName, result, isError }` from `payload.isError` (defaults to `false`) |
35
35
  * | `tool-error` | `tool-result` | `{ toolCallId, toolName, result: error, isError: true}`|
36
36
  * | `tool-output-denied` | `tool-result` | `{ toolCallId, toolName, result: reason, isError: true}`|
37
37
  * | `tool-output` | `tool-progress` | `{ toolCallId, toolName, output }` |
@@ -3,6 +3,7 @@
3
3
  * See LICENSE.txt for license terms.
4
4
  */
5
5
  import { CONTEXT_LENGTH_EXCEEDED_CODE, createContextLengthExceededError } from '@salesforce/sfdx-agent-sdk';
6
+ import { hasToolArgsValidationErrorMarker } from './tool-args-marker.js';
6
7
  import { isClassifiedStreamError } from './stream-errors.js';
7
8
  /**
8
9
  * Adapts Mastra `ChunkType` stream events to our {@link ChatEvent} discriminated union.
@@ -34,7 +35,7 @@ import { isClassifiedStreamError } from './stream-errors.js';
34
35
  * | `reasoning-delta` | `reasoning-delta` | `{ text }` from `payload.text` |
35
36
  * | `tool-call` | `tool-call` | `{ toolCallId, toolName, args }` |
36
37
  * | `tool-call-approval` | `tool-approval-request` | `{ toolCall: { toolCallId, toolName, args } }` — stream suspends |
37
- * | `tool-result` | `tool-result` | `{ toolCallId, toolName, result, isError: false }` |
38
+ * | `tool-result` | `tool-result` | `{ toolCallId, toolName, result, isError }` from `payload.isError` (defaults to `false`) |
38
39
  * | `tool-error` | `tool-result` | `{ toolCallId, toolName, result: error, isError: true}`|
39
40
  * | `tool-output-denied` | `tool-result` | `{ toolCallId, toolName, result: reason, isError: true}`|
40
41
  * | `tool-output` | `tool-progress` | `{ toolCallId, toolName, output }` |
@@ -106,7 +107,16 @@ export class MastraEventAdapter {
106
107
  toolCallId: chunk.payload.toolCallId,
107
108
  toolName: chunk.payload.toolName,
108
109
  result: chunk.payload.result,
109
- isError: false,
110
+ // A gated (tool-args-validation) consumer tool-call surfaces its
111
+ // `tool-call` + error `tool-result` on the LIVE stream just like an
112
+ // MCP/built-in tool whose executor rejected bad args (W-24023028) —
113
+ // the model already received the error, and the consumer observes
114
+ // the failed activity. Mastra's live chunk can drop the `isError`
115
+ // flag (its chunk-collection pipeline writes `state: 'result'`
116
+ // regardless — the same reason the persisted read path recovers it),
117
+ // so OR in the marker to keep the live event's `isError` honest.
118
+ isError: (chunk.payload.isError ?? false) ||
119
+ hasToolArgsValidationErrorMarker(chunk.payload.providerMetadata),
110
120
  };
111
121
  case 'tool-error': {
112
122
  const error = chunk.payload.error instanceof Error ? chunk.payload.error : new Error(String(chunk.payload.error));
@@ -297,6 +297,28 @@ export declare class MastraHarness implements MastraAgentHarness {
297
297
  private readonly innerFetch;
298
298
  private readonly wireObserverAbortController;
299
299
  private readonly languageModelBuilders?;
300
+ /**
301
+ * Per-turn, per-thread consumer/frontend tool-args retry counters (S1,
302
+ * W-24023028): `agentId → threadId → (toolName → consecutive-failure count)`.
303
+ * The agent's `LanguageModel` is built once for its whole lifetime, so a
304
+ * counter owned by the language model would bleed across turns AND across
305
+ * concurrent threads; owning it here — the innermost map reset at each
306
+ * `stream()` (per-turn) and keyed by thread (per-thread) — matches Claude's
307
+ * per-`stream()` `toolArgFailureCounts` and OpenAI's per-turn
308
+ * `consumerToolArgsAttempts`, so the exhaustion-clause wording is identical
309
+ * across all three harnesses. Nested (rather than a `${agentId}::${threadId}`
310
+ * string key) so a consumer id containing the delimiter can't alias two
311
+ * distinct scopes, and cleanup is O(1): `destroyThread` deletes the thread
312
+ * entry, `destroyAgent` deletes the agent entry, `shutdown` clears all.
313
+ */
314
+ private readonly toolArgsRetryCounts;
315
+ /**
316
+ * Fallback counter for a `doStream` / `doGenerate` with no scope (e.g. the
317
+ * thread-less `compactThread` summarize, which has no consumer tools). One
318
+ * stable map — NOT a fresh map per resolve — so repeated same-named calls in
319
+ * one such request still accumulate rather than resetting to attempt 1.
320
+ */
321
+ private readonly unscopedToolArgsRetryCounts;
300
322
  constructor(storageRootFolder: string, opts?: MastraHarnessOptions);
301
323
  /**
302
324
  * @requirements
@@ -480,6 +502,17 @@ export declare class MastraHarness implements MastraAgentHarness {
480
502
  * via G8 before any harness work runs (`AgentSDKErrorType.MODEL_NOT_SUPPORTED_BY_HARNESS`),
481
503
  * so a hint not handled here is a programming error worth surfacing eagerly.
482
504
  */
505
+ /**
506
+ * Resolves the per-turn tool-args retry-counter map for a `doStream` /
507
+ * `doGenerate` call from its `options.providerOptions` scope (S1,
508
+ * W-24023028). `stream()` pre-seeds a fresh innermost map for the turn's
509
+ * `(agentId, threadId)` before `agent.stream`, so a resolve during the turn
510
+ * returns that map; a call with no scope (e.g. the thread-less
511
+ * `compactThread` summarize, which has no consumer tools) gets the single
512
+ * stable {@link unscopedToolArgsRetryCounts} map (never a fresh one per
513
+ * lookup, so repeated same-named calls still accumulate).
514
+ */
515
+ private resolveToolArgsRetryCounts;
483
516
  private buildLanguageModel;
484
517
  private destroyThreadBestEffort;
485
518
  /**
@@ -27,6 +27,7 @@ import { validateMultimodalFiles } from '@salesforce/sfdx-agent-sdk';
27
27
  import { buildAnthropicDirectLanguageModel, buildAnthropicLanguageModel } from './pass-through-anthropic.js';
28
28
  import { buildOpenAIChatCompletionsLanguageModel } from './pass-through-openai-chat-completions.js';
29
29
  import { buildOpenAILanguageModel } from './pass-through-openai.js';
30
+ import { readToolArgsRetryScope, TOOL_ARGS_RETRY_SCOPE_AGENT_ID, TOOL_ARGS_RETRY_SCOPE_PROVIDER, TOOL_ARGS_RETRY_SCOPE_THREAD_ID, } from './v3-stream-mapping.js';
30
31
  import { ToolResultRedactionProcessor } from './mastra-tool-result-redaction-processor.js';
31
32
  import { backfillCreatedAt, buildSummaryPrompt, Clock, getErrorMessage, RealClock, splitFrontmatterAndBody, UUIDGenerator, } from '@salesforce/agentic-common';
32
33
  import { makeAbortable } from './utils.js';
@@ -468,6 +469,28 @@ export class MastraHarness {
468
469
  innerFetch;
469
470
  wireObserverAbortController = new AbortController();
470
471
  languageModelBuilders;
472
+ /**
473
+ * Per-turn, per-thread consumer/frontend tool-args retry counters (S1,
474
+ * W-24023028): `agentId → threadId → (toolName → consecutive-failure count)`.
475
+ * The agent's `LanguageModel` is built once for its whole lifetime, so a
476
+ * counter owned by the language model would bleed across turns AND across
477
+ * concurrent threads; owning it here — the innermost map reset at each
478
+ * `stream()` (per-turn) and keyed by thread (per-thread) — matches Claude's
479
+ * per-`stream()` `toolArgFailureCounts` and OpenAI's per-turn
480
+ * `consumerToolArgsAttempts`, so the exhaustion-clause wording is identical
481
+ * across all three harnesses. Nested (rather than a `${agentId}::${threadId}`
482
+ * string key) so a consumer id containing the delimiter can't alias two
483
+ * distinct scopes, and cleanup is O(1): `destroyThread` deletes the thread
484
+ * entry, `destroyAgent` deletes the agent entry, `shutdown` clears all.
485
+ */
486
+ toolArgsRetryCounts = new Map();
487
+ /**
488
+ * Fallback counter for a `doStream` / `doGenerate` with no scope (e.g. the
489
+ * thread-less `compactThread` summarize, which has no consumer tools). One
490
+ * stable map — NOT a fresh map per resolve — so repeated same-named calls in
491
+ * one such request still accumulate rather than resetting to attempt 1.
492
+ */
493
+ unscopedToolArgsRetryCounts = new Map();
471
494
  constructor(storageRootFolder, opts = {}) {
472
495
  this.storageRootFolder = storageRootFolder;
473
496
  this.clock = opts.clock ?? new RealClock();
@@ -530,6 +553,8 @@ export class MastraHarness {
530
553
  }));
531
554
  this.activeAgents.clear();
532
555
  this.modelConnectivityByAgent.clear();
556
+ this.toolArgsRetryCounts.clear();
557
+ this.unscopedToolArgsRetryCounts.clear();
533
558
  await this.mastra?.shutdown();
534
559
  this.buses.dispose();
535
560
  }
@@ -612,6 +637,7 @@ export class MastraHarness {
612
637
  // race the consumer-tool bridge call, so we resolve the coordinator
613
638
  // each time rather than capturing it once at agent-creation.
614
639
  const consumerToolNames = new Set((config.tools ?? []).map((t) => t.name));
640
+ const consumerToolSchemas = new Map((config.tools ?? []).map((t) => [t.name, t.inputSchema]));
615
641
  const parkerLookup = (threadId) => this.activeAgents.get(agentId)?.coordinators.get(threadId)?.parkConsumerToolCall;
616
642
  const customTools = config.tools?.length ? mapToolDefinitionsToMastraTools(config.tools, parkerLookup) : {};
617
643
  // Build the language model from the live ModelConnectivityInfo bag. The
@@ -628,7 +654,7 @@ export class MastraHarness {
628
654
  }
629
655
  return info;
630
656
  };
631
- const resolvedModel = this.buildLanguageModel(getInfoForAgent);
657
+ const resolvedModel = this.buildLanguageModel(getInfoForAgent, () => consumerToolNames, () => consumerToolSchemas);
632
658
  const validateFiles = (files) => validateMultimodalFiles(files, getInfoForAgent().model);
633
659
  let mastraWorkspace;
634
660
  let agent;
@@ -800,6 +826,8 @@ export class MastraHarness {
800
826
  }));
801
827
  state.skillSearchProcessor?.dispose();
802
828
  await state.mastraWorkspace?.destroy();
829
+ // Drop every per-turn tool-args retry counter for this agent's threads (S1).
830
+ this.toolArgsRetryCounts.delete(agentId);
803
831
  // The store is separate from Mastra's thread rows, so it needs its own
804
832
  // teardown — remove every thread's stored context for this agent.
805
833
  await this.sessionContextStore.deleteAgent(agentId);
@@ -1107,6 +1135,7 @@ export class MastraHarness {
1107
1135
  }
1108
1136
  // ── Phase 2: non-MCP rebuild ──────────────────────────────────────
1109
1137
  const consumerToolNames = new Set((config.tools ?? []).map((t) => t.name));
1138
+ const consumerToolSchemas = new Map((config.tools ?? []).map((t) => [t.name, t.inputSchema]));
1110
1139
  const parkerLookup = (threadId) => this.activeAgents.get(agentId)?.coordinators.get(threadId)?.parkConsumerToolCall;
1111
1140
  const customTools = config.tools?.length ? mapToolDefinitionsToMastraTools(config.tools, parkerLookup) : {};
1112
1141
  // Same shape as createAgent's getter — reads the live bag on every call so
@@ -1125,7 +1154,7 @@ export class MastraHarness {
1125
1154
  // `Agent` unconditionally on every `updateAgent` (instructions, tools,
1126
1155
  // processors all flow through), so we always rebuild the language model
1127
1156
  // here too. Allocation cost is negligible compared to the Agent rebuild.
1128
- const resolvedModel = this.buildLanguageModel(getInfoForAgent);
1157
+ const resolvedModel = this.buildLanguageModel(getInfoForAgent, () => consumerToolNames, () => consumerToolSchemas);
1129
1158
  const validateFiles = (files) => validateMultimodalFiles(files, getInfoForAgent().model);
1130
1159
  // Capture the live mcpServers map by reference — the new Agent's
1131
1160
  // tools() closure will read it on every step, so preserved clients'
@@ -1271,6 +1300,7 @@ export class MastraHarness {
1271
1300
  coordinator.dispose();
1272
1301
  state.coordinators.delete(threadId);
1273
1302
  }
1303
+ this.toolArgsRetryCounts.get(agentId)?.delete(threadId);
1274
1304
  const memory = await this.getMemoryOrThrow(agentId);
1275
1305
  await memory.deleteThread(threadId);
1276
1306
  // The store is separate from Mastra's thread row, so delete it too or a
@@ -1477,7 +1507,34 @@ export class MastraHarness {
1477
1507
  * via G8 before any harness work runs (`AgentSDKErrorType.MODEL_NOT_SUPPORTED_BY_HARNESS`),
1478
1508
  * so a hint not handled here is a programming error worth surfacing eagerly.
1479
1509
  */
1480
- buildLanguageModel(getInfo) {
1510
+ /**
1511
+ * Resolves the per-turn tool-args retry-counter map for a `doStream` /
1512
+ * `doGenerate` call from its `options.providerOptions` scope (S1,
1513
+ * W-24023028). `stream()` pre-seeds a fresh innermost map for the turn's
1514
+ * `(agentId, threadId)` before `agent.stream`, so a resolve during the turn
1515
+ * returns that map; a call with no scope (e.g. the thread-less
1516
+ * `compactThread` summarize, which has no consumer tools) gets the single
1517
+ * stable {@link unscopedToolArgsRetryCounts} map (never a fresh one per
1518
+ * lookup, so repeated same-named calls still accumulate).
1519
+ */
1520
+ resolveToolArgsRetryCounts(options) {
1521
+ const scope = readToolArgsRetryScope(options);
1522
+ if (scope === undefined)
1523
+ return this.unscopedToolArgsRetryCounts;
1524
+ let byThread = this.toolArgsRetryCounts.get(scope.agentId);
1525
+ if (!byThread) {
1526
+ byThread = new Map();
1527
+ this.toolArgsRetryCounts.set(scope.agentId, byThread);
1528
+ }
1529
+ let counts = byThread.get(scope.threadId);
1530
+ if (!counts) {
1531
+ counts = new Map();
1532
+ byThread.set(scope.threadId, counts);
1533
+ }
1534
+ return counts;
1535
+ }
1536
+ buildLanguageModel(getInfo, getConsumerToolNames, getConsumerToolSchemas) {
1537
+ const getToolArgsRetryCounts = (options) => this.resolveToolArgsRetryCounts(options);
1481
1538
  const initial = getInfo();
1482
1539
  const logBus = this.buses.getLogBus();
1483
1540
  // The fetch wrapper emits one WireCommunicationEvent pair per HTTP call. Forward
@@ -1507,17 +1564,44 @@ export class MastraHarness {
1507
1564
  switch (initial.providerHint) {
1508
1565
  case 'bedrock-anthropic':
1509
1566
  // Salesforce LLM Gateway's `/invoke-with-response-stream` (Bedrock pass-through).
1510
- return buildAnthropicLanguageModel({ getInfo, logBus, wireHooks, innerFetch, idleReadTimeoutMs });
1567
+ return buildAnthropicLanguageModel({
1568
+ getInfo,
1569
+ logBus,
1570
+ wireHooks,
1571
+ innerFetch,
1572
+ getConsumerToolNames,
1573
+ getConsumerToolSchemas,
1574
+ getToolArgsRetryCounts,
1575
+ idleReadTimeoutMs,
1576
+ });
1511
1577
  case 'anthropic':
1512
1578
  // Direct Anthropic Messages API (`api.anthropic.com/v1/messages`) —
1513
1579
  // different wire shape (`x-api-key` auth, body without `anthropic_version`).
1514
- return buildAnthropicDirectLanguageModel({ getInfo, logBus, wireHooks, innerFetch, idleReadTimeoutMs });
1580
+ return buildAnthropicDirectLanguageModel({
1581
+ getInfo,
1582
+ logBus,
1583
+ wireHooks,
1584
+ innerFetch,
1585
+ getConsumerToolNames,
1586
+ getConsumerToolSchemas,
1587
+ getToolArgsRetryCounts,
1588
+ idleReadTimeoutMs,
1589
+ });
1515
1590
  case 'openai-responses':
1516
1591
  case 'openai':
1517
1592
  // OpenAI Responses API. Salesforce LLM Gateway's `/responses` pass-through
1518
1593
  // for `'openai-responses'`; `api.openai.com/v1/responses` for `'openai'` direct.
1519
1594
  // Same wire shape; the only difference is the `baseUrl` the resolver supplies.
1520
- return buildOpenAILanguageModel({ getInfo, logBus, wireHooks, innerFetch, idleReadTimeoutMs });
1595
+ return buildOpenAILanguageModel({
1596
+ getInfo,
1597
+ logBus,
1598
+ wireHooks,
1599
+ innerFetch,
1600
+ getConsumerToolNames,
1601
+ getConsumerToolSchemas,
1602
+ getToolArgsRetryCounts,
1603
+ idleReadTimeoutMs,
1604
+ });
1521
1605
  case 'openai-compatible':
1522
1606
  // OpenAI-compatible Chat Completions API (LLMG Express, LiteLLM, etc.).
1523
1607
  // Different wire shape from Responses — `/v1/chat/completions` with
@@ -1527,6 +1611,9 @@ export class MastraHarness {
1527
1611
  logBus,
1528
1612
  wireHooks,
1529
1613
  innerFetch,
1614
+ getConsumerToolNames,
1615
+ getConsumerToolSchemas,
1616
+ getToolArgsRetryCounts,
1530
1617
  idleReadTimeoutMs,
1531
1618
  });
1532
1619
  default: {
@@ -1688,6 +1775,17 @@ export class MastraHarness {
1688
1775
  // failing the turn rather than silently dropping context for it.
1689
1776
  const sessionContext = await this.sessionContextStore.read(agentId, threadId);
1690
1777
  const systemContext = renderSessionContextSystemText(sessionContext);
1778
+ // S1 (W-24023028): reset this turn's consumer/frontend tool-args retry
1779
+ // counter (a fresh innermost map for this thread) and stamp its scope so
1780
+ // every `doStream` step of this one streamed run resolves the SAME
1781
+ // per-turn, per-thread counter (the `LanguageModel` is agent-lifetime, so
1782
+ // it cannot scope this itself).
1783
+ let retryCountsByThread = this.toolArgsRetryCounts.get(agentId);
1784
+ if (!retryCountsByThread) {
1785
+ retryCountsByThread = new Map();
1786
+ this.toolArgsRetryCounts.set(agentId, retryCountsByThread);
1787
+ }
1788
+ retryCountsByThread.set(threadId, new Map());
1691
1789
  const output = await agentState.agent.stream(streamInput, {
1692
1790
  memory: {
1693
1791
  thread: threadId,
@@ -1696,6 +1794,12 @@ export class MastraHarness {
1696
1794
  requestContext: this.buildRequestContext(agentId, threadId),
1697
1795
  maxSteps: options?.maxSteps ?? DEFAULT_MAX_STEPS,
1698
1796
  abortSignal: options?.abortSignal,
1797
+ providerOptions: {
1798
+ [TOOL_ARGS_RETRY_SCOPE_PROVIDER]: {
1799
+ [TOOL_ARGS_RETRY_SCOPE_AGENT_ID]: agentId,
1800
+ [TOOL_ARGS_RETRY_SCOPE_THREAD_ID]: threadId,
1801
+ },
1802
+ },
1699
1803
  ...(systemContext.length > 0 ? { system: systemContext } : {}),
1700
1804
  // Pass `requireToolApproval: true` to Mastra iff the gate is active
1701
1805
  // so Mastra suspends every non-consumer tool with a
@@ -2,6 +2,7 @@
2
2
  * Copyright 2026, Salesforce, Inc. All rights reserved.
3
3
  * See LICENSE.txt for license terms.
4
4
  */
5
+ import { hasToolArgsValidationErrorMarker } from './tool-args-marker.js';
5
6
  /**
6
7
  * Reads the SDK `fileName` stashed under `providerMetadata.salesforce.fileName` when a file part
7
8
  * was persisted via {@link mapMessagePartToMastra}. Returns `undefined` when absent or not a string.
@@ -166,9 +167,21 @@ export function mapMastraPartToMessagePart(part, logBus) {
166
167
  // that shape itself; it's an accepted edge of accepting arbitrary
167
168
  // caller-supplied history.
168
169
  if (inv.state === 'result') {
170
+ // A gated consumer-tool call (W-24023028) is persisted by Mastra's own
171
+ // live-stream pipeline as a plain `state: 'result'` invocation — Mastra
172
+ // drops the stream chunk's `isError` flag before it ever reaches storage
173
+ // (see `hasToolArgsValidationErrorMarker`'s doc comment). Recover it from
174
+ // the marker `buildValidatedToolCallChunk` stamps in `providerMetadata`.
175
+ const isError = hasToolArgsValidationErrorMarker(part.providerMetadata);
169
176
  return [
170
177
  toolCall,
171
- { type: 'tool-result', toolCallId: inv.toolCallId, toolName: inv.toolName, result: inv.result },
178
+ {
179
+ type: 'tool-result',
180
+ toolCallId: inv.toolCallId,
181
+ toolName: inv.toolName,
182
+ result: inv.result,
183
+ ...(isError ? { isError: true } : {}),
184
+ },
172
185
  ];
173
186
  }
174
187
  if (inv.state === 'output-error' || inv.state === 'output-denied') {
@@ -3,6 +3,7 @@
3
3
  * See LICENSE.txt for license terms.
4
4
  */
5
5
  import { MASTRA_THREAD_ID_KEY } from '@mastra/core/request-context';
6
+ import { toStandardSchema } from '@mastra/schema-compat';
6
7
  /**
7
8
  * Converts SDK {@link ToolDefinition} objects into Mastra-compatible
8
9
  * {@link ToolAction} entries.
@@ -37,7 +38,27 @@ export function mapToolDefinitionsToMastraTools(tools, parkerLookup) {
37
38
  const mastraTool = {
38
39
  id: tool.name,
39
40
  description: tool.description ?? '',
40
- inputSchema: ensureJsonSchemaMarker(tool.inputSchema),
41
+ // Register the consumer's declared JSON Schema as a Standard Schema
42
+ // (`toStandardSchema`) rather than a bare JSON object (W-24023028).
43
+ // A bare object is neither a Zod type nor a StandardSchemaWithJSON, so
44
+ // Mastra's `CoreToolBuilder` sends it through
45
+ // `applyCompatLayer(mode:'aiSdkSchema')` — a JSON→Zod→AI-SDK round-trip
46
+ // (`convertSchemaToZod`) that SILENTLY DROPS `additionalProperties:true`
47
+ // and strictifies every nested object (proven: a valid `show_widget`
48
+ // Mosaic tree failed the gate with "must NOT have additional
49
+ // properties"). A StandardSchemaWithJSON instead takes the builder's
50
+ // preserving branch (`jsonSchema(standardSchemaToJSONSchema(...))`), so
51
+ // the declared schema — `additionalProperties` and all — reaches the
52
+ // wire `input_schema` (what the model sees), `options.tools` (what the
53
+ // args-gate validates against), and AI-SDK arg parsing intact.
54
+ inputSchema: toStandardSchema(ensureJsonSchemaMarker(tool.inputSchema)),
55
+ // `strict: false` keeps a provider compat-layer (OpenAI/Anthropic
56
+ // strict-mode) from RE-degrading the preserved schema on the way to the
57
+ // wire — arbitrary consumer schemas aren't structured-output schemas
58
+ // (the OpenAI harness sets `strict: false` on consumer tools for the
59
+ // same reason). Without it, `CoreToolBuilder.build` applies a matching
60
+ // compat layer that reintroduces `additionalProperties:false`.
61
+ strict: false,
41
62
  // Issue #529 step 2c (Path A) — consumer tools bypass the
42
63
  // approval gate by construction. With `requireApproval: false`,
43
64
  // Mastra parks the agentic run on the parker Promise without
@@ -29,6 +29,44 @@ export declare function buildAnthropicLanguageModel(opts: {
29
29
  * don't need to thread it.
30
30
  */
31
31
  innerFetch?: typeof fetch;
32
+ /**
33
+ * Getter for the names of the agent's consumer/frontend tools
34
+ * (W-24023028), invoked on every tool-call emission. This is NOT the
35
+ * `getInfo`-style live getter that reads mutating agent state — freshness
36
+ * across `updateAgentConfig` comes from the harness REBUILDING the whole
37
+ * language model (a new closure) when tools change; the getter captures a
38
+ * per-config snapshot, so re-reading it per call only matters within the
39
+ * lifetime of one built model (it costs nothing and keeps the call sites
40
+ * uniform with the true live getters). Defaults to an empty set (no arg
41
+ * validation) for callers that don't thread it (e.g. tests exercising this
42
+ * builder in isolation).
43
+ */
44
+ getConsumerToolNames?: () => ReadonlySet<string>;
45
+ /**
46
+ * Getter for the consumer/frontend tools' PRISTINE declared `inputSchema`
47
+ * map (bare tool name → schema, from `AgentConfig.tools`). The args-gate
48
+ * validates against THIS rather than `options.tools[].inputSchema` — Mastra's
49
+ * tool-prep re-serializes the latter (`toStandardSchema` →
50
+ * `standardSchemaToJSONSchema`), which injects `additionalProperties: false`
51
+ * onto any object node that declared none (JSON Schema's default is open).
52
+ * Gating against that closed schema would reject extra keys on an
53
+ * implicitly-open nested object and diverge from Claude/OpenAI (which gate
54
+ * the pristine declared schema); validating the declared schema keeps the
55
+ * gate verdict identical across all three harnesses (W-24023028). Same
56
+ * per-config-snapshot freshness caveat as `getConsumerToolNames`. Defaults to
57
+ * an empty map (no schema → the gate treats every consumer tool as
58
+ * unvalidatable-`ok`) for callers that don't thread it.
59
+ */
60
+ getConsumerToolSchemas?: () => ReadonlyMap<string, Record<string, unknown>>;
61
+ /**
62
+ * Resolver for the per-turn, per-thread tool-args retry-counter map (S1,
63
+ * W-24023028). Read fresh per `doStream` / `doGenerate` from that call's
64
+ * `options.providerOptions` scope key, so the counter resets per turn and is
65
+ * isolated per thread rather than accumulating for the whole agent-lifetime
66
+ * `LanguageModel`. Defaults to a single per-instance map for callers that
67
+ * don't thread it (tests / consumer-injected builders).
68
+ */
69
+ getToolArgsRetryCounts?: (options: LanguageModelV3CallOptions) => Map<string, number>;
32
70
  /** Per-read idle timeout (ms) for the gateway response body; see `createGatewayFetch`. */
33
71
  idleReadTimeoutMs?: number;
34
72
  }): LanguageModelV3;
@@ -58,6 +96,12 @@ export declare function buildAnthropicDirectLanguageModel(opts: {
58
96
  wireHooks?: GatewayFetchWireHooks;
59
97
  /** See {@link buildAnthropicLanguageModel} `innerFetch`. */
60
98
  innerFetch?: typeof fetch;
99
+ /** See {@link buildAnthropicLanguageModel} `getConsumerToolNames`. */
100
+ getConsumerToolNames?: () => ReadonlySet<string>;
101
+ /** See {@link buildAnthropicLanguageModel} `getConsumerToolSchemas`. */
102
+ getConsumerToolSchemas?: () => ReadonlyMap<string, Record<string, unknown>>;
103
+ /** See {@link buildAnthropicLanguageModel} `getToolArgsRetryCounts`. */
104
+ getToolArgsRetryCounts?: (options: LanguageModelV3CallOptions) => Map<string, number>;
61
105
  /** Per-read idle timeout (ms) for the gateway response body; see `createGatewayFetch`. */
62
106
  idleReadTimeoutMs?: number;
63
107
  }): LanguageModelV3;
@@ -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, createGatewayFetch, isEmptyUsage, isParameterExcluded, mapAnthropicFinishReason, mapAnthropicUsage, normalizeToolArgsString, toolResultOutputToString, } from './v3-stream-mapping.js';
8
+ import { TextSpanLifecycle, 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.
@@ -21,13 +21,17 @@ import { TextSpanLifecycle, createGatewayFetch, isEmptyUsage, isParameterExclude
21
21
  * the next request.
22
22
  */
23
23
  export function buildAnthropicLanguageModel(opts) {
24
- const { getInfo, logBus, emitWireCommunication, wireHooks, innerFetch, idleReadTimeoutMs } = opts;
24
+ const { getInfo, logBus, emitWireCommunication, wireHooks, innerFetch, getConsumerToolNames, getConsumerToolSchemas, getToolArgsRetryCounts, idleReadTimeoutMs, } = opts;
25
25
  const initialInfo = getInfo();
26
+ const fallbackRetryCounts = new Map();
26
27
  const ctx = {
27
28
  getInfo,
28
29
  logBus,
29
30
  emitWireCommunication,
30
31
  wireHooks,
32
+ getConsumerToolNames: getConsumerToolNames ?? (() => EMPTY_TOOL_NAMES),
33
+ getConsumerToolSchemas: getConsumerToolSchemas ?? (() => EMPTY_TOOL_SCHEMAS),
34
+ getToolArgsRetryCounts: getToolArgsRetryCounts ?? (() => fallbackRetryCounts),
31
35
  buildClient: (info) => new AnthropicBedrock({
32
36
  baseURL: info.baseUrl,
33
37
  skipAuth: true,
@@ -70,13 +74,17 @@ export function buildAnthropicLanguageModel(opts) {
70
74
  * language model.
71
75
  */
72
76
  export function buildAnthropicDirectLanguageModel(opts) {
73
- const { getInfo, logBus, emitWireCommunication, wireHooks, innerFetch, idleReadTimeoutMs } = opts;
77
+ const { getInfo, logBus, emitWireCommunication, wireHooks, innerFetch, getConsumerToolNames, getConsumerToolSchemas, getToolArgsRetryCounts, idleReadTimeoutMs, } = opts;
74
78
  const initialInfo = getInfo();
79
+ const fallbackRetryCounts = new Map();
75
80
  const ctx = {
76
81
  getInfo,
77
82
  logBus,
78
83
  emitWireCommunication,
79
84
  wireHooks,
85
+ getConsumerToolNames: getConsumerToolNames ?? (() => EMPTY_TOOL_NAMES),
86
+ getConsumerToolSchemas: getConsumerToolSchemas ?? (() => EMPTY_TOOL_SCHEMAS),
87
+ getToolArgsRetryCounts: getToolArgsRetryCounts ?? (() => fallbackRetryCounts),
80
88
  buildClient: (info) =>
81
89
  // Anthropic SDK constructor takes `apiKey` and writes `x-api-key` itself.
82
90
  // We use a non-empty placeholder here because the SDK requires `apiKey`
@@ -121,6 +129,8 @@ async function translateBearerToXApiKey(info) {
121
129
  }
122
130
  return out;
123
131
  }
132
+ const EMPTY_TOOL_NAMES = new Set();
133
+ const EMPTY_TOOL_SCHEMAS = new Map();
124
134
  /**
125
135
  * Builds the optional `wireHooks` arg for `createGatewayFetch` — only set when
126
136
  * the harness has wired a `WireCommunicationEvent` callback. Reads the model id
@@ -144,6 +154,7 @@ async function buildAnthropicRequest(ctx, options) {
144
154
  const client = ctx.buildClient(info);
145
155
  const { system, messages } = translatePromptToAnthropic(options.prompt);
146
156
  const tools = translateToolsToAnthropic(options);
157
+ const toolSchemas = ctx.getConsumerToolSchemas();
147
158
  const commonParams = {
148
159
  model: info.nativeModelId,
149
160
  // Precedence: an explicit per-request `maxOutputTokens` wins; otherwise
@@ -166,10 +177,10 @@ async function buildAnthropicRequest(ctx, options) {
166
177
  }
167
178
  if (options.stopSequences !== undefined)
168
179
  commonParams.stop_sequences = [...options.stopSequences];
169
- return { client, commonParams };
180
+ return { client, commonParams, toolSchemas };
170
181
  }
171
182
  async function doGenerate(ctx, options) {
172
- const { client, commonParams } = await buildAnthropicRequest(ctx, options);
183
+ const { client, commonParams, toolSchemas } = await buildAnthropicRequest(ctx, options);
173
184
  // Forward the turn's AbortSignal into the SDK's RequestOptions so firing the
174
185
  // controller (POST /cancel, idle timeout, shutdown abortAll) actually aborts
175
186
  // the outbound gateway fetch, not just harness bookkeeping (W-23988781).
@@ -189,12 +200,8 @@ async function doGenerate(ctx, options) {
189
200
  }
190
201
  else if (block.type === 'tool_use') {
191
202
  sawToolCalls = true;
192
- content.push({
193
- type: 'tool-call',
194
- toolCallId: block.id,
195
- toolName: block.name,
196
- input: typeof block.input === 'string' ? block.input : JSON.stringify(block.input ?? {}),
197
- });
203
+ const argsBuffer = typeof block.input === 'string' ? block.input : JSON.stringify(block.input ?? {});
204
+ content.push(...buildValidatedToolCallChunk(block.id, block.name, argsBuffer, toolSchemas.get(block.name), ctx.getConsumerToolNames().has(block.name), { retryCounts: ctx.getToolArgsRetryCounts(options), logBus: ctx.logBus }));
198
205
  }
199
206
  // Other block types (thinking, server-tool-result, etc.) are not in
200
207
  // the V3 ChatEvent surface — same posture as doStream.
@@ -210,7 +217,7 @@ async function doGenerate(ctx, options) {
210
217
  return { content, finishReason, usage, warnings: [] };
211
218
  }
212
219
  async function doStream(ctx, options) {
213
- const { client, commonParams } = await buildAnthropicRequest(ctx, options);
220
+ const { client, commonParams, toolSchemas } = await buildAnthropicRequest(ctx, options);
214
221
  // See doGenerate: thread the AbortSignal into the SDK so a mid-stream abort
215
222
  // tears down the live gateway socket (W-23988781).
216
223
  let sdkStream;
@@ -313,14 +320,9 @@ async function doStream(ctx, options) {
313
320
  if (tu) {
314
321
  sawToolCalls = true;
315
322
  sawContentOrUsage = true;
316
- controller.enqueue({
317
- type: 'tool-call',
318
- toolCallId: tu.id,
319
- toolName: tu.name,
320
- // No-arg tool → empty argsBuffer; normalize to '{}'.
321
- // See normalizeToolArgsString (W-23789221).
322
- input: normalizeToolArgsString(tu.argsBuffer),
323
- });
323
+ 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 })) {
324
+ controller.enqueue(part);
325
+ }
324
326
  toolUseByIndex.delete(event.index);
325
327
  }
326
328
  break;
@@ -30,6 +30,12 @@ export declare function buildOpenAIChatCompletionsLanguageModel(opts: {
30
30
  wireHooks?: GatewayFetchWireHooks;
31
31
  /** See `buildAnthropicLanguageModel` `innerFetch`. */
32
32
  innerFetch?: typeof fetch;
33
+ /** See `buildAnthropicLanguageModel` `getConsumerToolNames`. */
34
+ getConsumerToolNames?: () => ReadonlySet<string>;
35
+ /** See `buildAnthropicLanguageModel` `getConsumerToolSchemas`. */
36
+ getConsumerToolSchemas?: () => ReadonlyMap<string, Record<string, unknown>>;
37
+ /** See `buildAnthropicLanguageModel` `getToolArgsRetryCounts`. */
38
+ getToolArgsRetryCounts?: (options: LanguageModelV3CallOptions) => Map<string, number>;
33
39
  /** Per-read idle timeout (ms) for the gateway response body; see `createGatewayFetch`. */
34
40
  idleReadTimeoutMs?: number;
35
41
  }): LanguageModelV3;
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import OpenAI from 'openai';
6
6
  import { EmptyStreamError } from './stream-errors.js';
7
- import { TextSpanLifecycle, createGatewayFetch, isEmptyUsage, isParameterExcluded, mapOpenAIUsage, normalizeToolArgsString, toolResultOutputToString, } from './v3-stream-mapping.js';
7
+ import { TextSpanLifecycle, 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
@@ -26,9 +26,20 @@ import { TextSpanLifecycle, createGatewayFetch, isEmptyUsage, isParameterExclude
26
26
  * without rebuilding the language model.
27
27
  */
28
28
  export function buildOpenAIChatCompletionsLanguageModel(opts) {
29
- const { getInfo, logBus, emitWireCommunication, wireHooks, innerFetch, idleReadTimeoutMs } = opts;
29
+ const { getInfo, logBus, emitWireCommunication, wireHooks, innerFetch, getConsumerToolNames, getConsumerToolSchemas, getToolArgsRetryCounts, idleReadTimeoutMs, } = opts;
30
30
  const initialInfo = getInfo();
31
- const ctx = { getInfo, logBus, emitWireCommunication, wireHooks, innerFetch, idleReadTimeoutMs };
31
+ const fallbackRetryCounts = new Map();
32
+ const ctx = {
33
+ getInfo,
34
+ logBus,
35
+ emitWireCommunication,
36
+ wireHooks,
37
+ innerFetch,
38
+ getConsumerToolNames: getConsumerToolNames ?? (() => EMPTY_TOOL_NAMES),
39
+ getConsumerToolSchemas: getConsumerToolSchemas ?? (() => EMPTY_TOOL_SCHEMAS),
40
+ getToolArgsRetryCounts: getToolArgsRetryCounts ?? (() => fallbackRetryCounts),
41
+ idleReadTimeoutMs,
42
+ };
32
43
  return {
33
44
  specificationVersion: 'v3',
34
45
  provider: 'openai-compatible-chat-completions-pass-through',
@@ -38,6 +49,8 @@ export function buildOpenAIChatCompletionsLanguageModel(opts) {
38
49
  doStream: (options) => doStream(ctx, options),
39
50
  };
40
51
  }
52
+ const EMPTY_TOOL_NAMES = new Set();
53
+ const EMPTY_TOOL_SCHEMAS = new Map();
41
54
  /**
42
55
  * Reads the live `ModelConnectivityInfo`, builds an `OpenAI` client pointed at
43
56
  * the OpenAI-compatible endpoint, and constructs the request body shape both
@@ -60,6 +73,7 @@ async function buildRequest(ctx, options) {
60
73
  });
61
74
  const messages = translatePromptToChatCompletions(options.prompt);
62
75
  const tools = translateToolsToChatCompletions(options);
76
+ const toolSchemas = ctx.getConsumerToolSchemas();
63
77
  const commonParams = {
64
78
  model: info.nativeModelId,
65
79
  messages,
@@ -74,10 +88,10 @@ async function buildRequest(ctx, options) {
74
88
  ? { stop: [...options.stopSequences] }
75
89
  : {}),
76
90
  };
77
- return { client, commonParams };
91
+ return { client, commonParams, toolSchemas };
78
92
  }
79
93
  async function doGenerate(ctx, options) {
80
- const { client, commonParams } = await buildRequest(ctx, options);
94
+ const { client, commonParams, toolSchemas } = await buildRequest(ctx, options);
81
95
  const params = { ...commonParams };
82
96
  // Forward the turn's AbortSignal into the SDK's RequestOptions so firing the
83
97
  // controller (POST /cancel, idle timeout, shutdown abortAll) actually aborts
@@ -94,13 +108,7 @@ async function doGenerate(ctx, options) {
94
108
  if (tc.type !== 'function')
95
109
  continue;
96
110
  sawToolCalls = true;
97
- content.push({
98
- type: 'tool-call',
99
- toolCallId: tc.id,
100
- toolName: tc.function.name,
101
- // No-arg tool → empty arguments; normalize to '{}' (W-23789221).
102
- input: normalizeToolArgsString(tc.function.arguments),
103
- });
111
+ content.push(...buildValidatedToolCallChunk(tc.id, tc.function.name, tc.function.arguments, toolSchemas.get(tc.function.name), ctx.getConsumerToolNames().has(tc.function.name), { retryCounts: ctx.getToolArgsRetryCounts(options), logBus: ctx.logBus }));
104
112
  }
105
113
  }
106
114
  const finishReason = mapChatCompletionFinishReason(choice?.finish_reason ?? null, sawToolCalls);
@@ -114,7 +122,7 @@ async function doGenerate(ctx, options) {
114
122
  return { content, finishReason, usage, warnings: [] };
115
123
  }
116
124
  async function doStream(ctx, options) {
117
- const { client, commonParams } = await buildRequest(ctx, options);
125
+ const { client, commonParams, toolSchemas } = await buildRequest(ctx, options);
118
126
  const params = {
119
127
  ...commonParams,
120
128
  stream: true,
@@ -224,13 +232,9 @@ async function doStream(ctx, options) {
224
232
  continue;
225
233
  }
226
234
  sawToolCalls = true;
227
- controller.enqueue({
228
- type: 'tool-call',
229
- toolCallId: acc.id,
230
- toolName: acc.name,
231
- // No-arg tool → empty argsBuffer; normalize to '{}' (W-23789221).
232
- input: normalizeToolArgsString(acc.argsBuffer),
233
- });
235
+ 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
+ controller.enqueue(part);
237
+ }
234
238
  }
235
239
  toolCallsByIndex.clear();
236
240
  }
@@ -22,6 +22,12 @@ export declare function buildOpenAILanguageModel(opts: {
22
22
  wireHooks?: GatewayFetchWireHooks;
23
23
  /** See `buildAnthropicLanguageModel` `innerFetch`. */
24
24
  innerFetch?: typeof fetch;
25
+ /** See `buildAnthropicLanguageModel` `getConsumerToolNames`. */
26
+ getConsumerToolNames?: () => ReadonlySet<string>;
27
+ /** See `buildAnthropicLanguageModel` `getConsumerToolSchemas`. */
28
+ getConsumerToolSchemas?: () => ReadonlyMap<string, Record<string, unknown>>;
29
+ /** See `buildAnthropicLanguageModel` `getToolArgsRetryCounts`. */
30
+ getToolArgsRetryCounts?: (options: LanguageModelV3CallOptions) => Map<string, number>;
25
31
  /** Per-read idle timeout (ms) for the gateway response body; see `createGatewayFetch`. */
26
32
  idleReadTimeoutMs?: number;
27
33
  }): LanguageModelV3;
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import OpenAI from 'openai';
6
6
  import { EmptyStreamError, GatewayResponseError } from './stream-errors.js';
7
- import { TextSpanLifecycle, createGatewayFetch, isEmptyUsage, isParameterExcluded, mapOpenAIFinishReason, mapOpenAIUsage, normalizeToolArgsString, toolResultOutputToString, } from './v3-stream-mapping.js';
7
+ import { TextSpanLifecycle, 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
@@ -18,9 +18,20 @@ import { TextSpanLifecycle, createGatewayFetch, isEmptyUsage, isParameterExclude
18
18
  * next request.
19
19
  */
20
20
  export function buildOpenAILanguageModel(opts) {
21
- const { getInfo, logBus, emitWireCommunication, wireHooks, innerFetch, idleReadTimeoutMs } = opts;
21
+ const { getInfo, logBus, emitWireCommunication, wireHooks, innerFetch, getConsumerToolNames, getConsumerToolSchemas, getToolArgsRetryCounts, idleReadTimeoutMs, } = opts;
22
22
  const initialInfo = getInfo();
23
- const ctx = { getInfo, logBus, emitWireCommunication, wireHooks, innerFetch, idleReadTimeoutMs };
23
+ const fallbackRetryCounts = new Map();
24
+ const ctx = {
25
+ getInfo,
26
+ logBus,
27
+ emitWireCommunication,
28
+ wireHooks,
29
+ innerFetch,
30
+ getConsumerToolNames: getConsumerToolNames ?? (() => EMPTY_TOOL_NAMES),
31
+ getConsumerToolSchemas: getConsumerToolSchemas ?? (() => EMPTY_TOOL_SCHEMAS),
32
+ getToolArgsRetryCounts: getToolArgsRetryCounts ?? (() => fallbackRetryCounts),
33
+ idleReadTimeoutMs,
34
+ };
24
35
  return {
25
36
  specificationVersion: 'v3',
26
37
  provider: 'salesforce-llm-gateway-pass-through-openai',
@@ -33,6 +44,8 @@ export function buildOpenAILanguageModel(opts) {
33
44
  doStream: (options) => doStream(ctx, options),
34
45
  };
35
46
  }
47
+ const EMPTY_TOOL_NAMES = new Set();
48
+ const EMPTY_TOOL_SCHEMAS = new Map();
36
49
  /**
37
50
  * Reads the live `ModelConnectivityInfo`, builds an `OpenAI` client pointed at
38
51
  * the gateway, and constructs the request body shape both `doStream` and
@@ -60,6 +73,7 @@ async function buildOpenAIRequest(ctx, options) {
60
73
  });
61
74
  const { input, instructions } = translatePromptToOpenAI(options.prompt);
62
75
  const tools = translateToolsToOpenAI(options);
76
+ const toolSchemas = ctx.getConsumerToolSchemas();
63
77
  const commonParams = {
64
78
  model: info.nativeModelId,
65
79
  input,
@@ -72,10 +86,10 @@ async function buildOpenAIRequest(ctx, options) {
72
86
  : {}),
73
87
  ...(options.maxOutputTokens !== undefined ? { max_output_tokens: options.maxOutputTokens } : {}),
74
88
  };
75
- return { client, commonParams };
89
+ return { client, commonParams, toolSchemas };
76
90
  }
77
91
  async function doGenerate(ctx, options) {
78
- const { client, commonParams } = await buildOpenAIRequest(ctx, options);
92
+ const { client, commonParams, toolSchemas } = await buildOpenAIRequest(ctx, options);
79
93
  const params = { ...commonParams };
80
94
  // Forward the turn's AbortSignal into the SDK's RequestOptions so firing the
81
95
  // controller (POST /cancel, idle timeout, shutdown abortAll) actually aborts
@@ -89,13 +103,7 @@ async function doGenerate(ctx, options) {
89
103
  for (const item of response.output) {
90
104
  if (item.type === 'function_call') {
91
105
  sawToolCalls = true;
92
- content.push({
93
- type: 'tool-call',
94
- toolCallId: item.call_id,
95
- toolName: item.name,
96
- // No-arg tool → empty arguments; normalize to '{}' (W-23789221).
97
- input: normalizeToolArgsString(item.arguments),
98
- });
106
+ content.push(...buildValidatedToolCallChunk(item.call_id, item.name, item.arguments, toolSchemas.get(item.name), ctx.getConsumerToolNames().has(item.name), { retryCounts: ctx.getToolArgsRetryCounts(options), logBus: ctx.logBus }));
99
107
  }
100
108
  // Other output-item variants (reasoning, web/file/code search, MCP) are
101
109
  // not in the V3 ChatEvent surface — same posture as doStream.
@@ -105,7 +113,7 @@ async function doGenerate(ctx, options) {
105
113
  return { content, finishReason, usage, warnings: [] };
106
114
  }
107
115
  async function doStream(ctx, options) {
108
- const { client, commonParams } = await buildOpenAIRequest(ctx, options);
116
+ const { client, commonParams, toolSchemas } = await buildOpenAIRequest(ctx, options);
109
117
  const params = { ...commonParams, stream: true };
110
118
  // See doGenerate: thread the AbortSignal into the SDK so a mid-stream abort
111
119
  // tears down the live gateway socket (W-23988781).
@@ -161,13 +169,9 @@ async function doStream(ctx, options) {
161
169
  const finalArgs = item.arguments ?? tc?.argsBuffer ?? '';
162
170
  sawToolCalls = true;
163
171
  sawContent = true;
164
- controller.enqueue({
165
- type: 'tool-call',
166
- toolCallId: item.call_id,
167
- toolName: item.name,
168
- // No-arg tool → empty finalArgs; normalize to '{}' (W-23789221).
169
- input: normalizeToolArgsString(finalArgs),
170
- });
172
+ 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
+ controller.enqueue(part);
174
+ }
171
175
  if (item.id)
172
176
  toolCallById.delete(item.id);
173
177
  toolCallById.delete(item.call_id);
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Marker the tool-args-validation gate (W-24023028) stamps on the synthetic
3
+ * error `tool-result` part it emits for a gated (malformed / schema-violating)
4
+ * consumer/frontend tool call. It rides on the AI-SDK `providerMetadata`
5
+ * channel because that is one of the few fields Mastra's own chunk-collection /
6
+ * persistence pipeline carries through onto both the live stream chunk
7
+ * (`ToolResultPayload`) AND the persisted `tool-invocation` part. Its SOLE job
8
+ * is to recover the `tool-result`'s `isError` flag — `buildMessagesFromChunks`
9
+ * unconditionally persists `state: 'result'` and drops `isError`, and the live
10
+ * chunk shape has no isError field either, so the read mapper
11
+ * (`mastra-message-mapper.ts`) and the event adapter (`mastra-event-adapter.ts`)
12
+ * OR this marker into `isError` on the persisted read and the live event
13
+ * respectively. (An earlier S4 design ALSO used it to suppress the
14
+ * consumer-visible `tool-call` ChatEvent; that suppression was removed —
15
+ * gated calls now surface a live `tool-call` + error `tool-result` on all three
16
+ * harnesses — so the marker no longer gates any event emission.)
17
+ *
18
+ * Single-sourced here so the writer (`v3-stream-mapping.ts`), the read mapper
19
+ * (`mastra-message-mapper.ts`), and the event adapter (`mastra-event-adapter.ts`)
20
+ * can never drift on the metadata key.
21
+ */
22
+ export declare const TOOL_ARGS_VALIDATION_ERROR_METADATA: {
23
+ readonly salesforce: {
24
+ readonly toolArgsValidationError: true;
25
+ };
26
+ };
27
+ /** True iff `providerMetadata` carries the {@link TOOL_ARGS_VALIDATION_ERROR_METADATA} marker. */
28
+ export declare function hasToolArgsValidationErrorMarker(providerMetadata: unknown): boolean;
@@ -0,0 +1,38 @@
1
+ /*
2
+ * Copyright 2026, Salesforce, Inc. All rights reserved.
3
+ * See LICENSE.txt for license terms.
4
+ */
5
+ /**
6
+ * Marker the tool-args-validation gate (W-24023028) stamps on the synthetic
7
+ * error `tool-result` part it emits for a gated (malformed / schema-violating)
8
+ * consumer/frontend tool call. It rides on the AI-SDK `providerMetadata`
9
+ * channel because that is one of the few fields Mastra's own chunk-collection /
10
+ * persistence pipeline carries through onto both the live stream chunk
11
+ * (`ToolResultPayload`) AND the persisted `tool-invocation` part. Its SOLE job
12
+ * is to recover the `tool-result`'s `isError` flag — `buildMessagesFromChunks`
13
+ * unconditionally persists `state: 'result'` and drops `isError`, and the live
14
+ * chunk shape has no isError field either, so the read mapper
15
+ * (`mastra-message-mapper.ts`) and the event adapter (`mastra-event-adapter.ts`)
16
+ * OR this marker into `isError` on the persisted read and the live event
17
+ * respectively. (An earlier S4 design ALSO used it to suppress the
18
+ * consumer-visible `tool-call` ChatEvent; that suppression was removed —
19
+ * gated calls now surface a live `tool-call` + error `tool-result` on all three
20
+ * harnesses — so the marker no longer gates any event emission.)
21
+ *
22
+ * Single-sourced here so the writer (`v3-stream-mapping.ts`), the read mapper
23
+ * (`mastra-message-mapper.ts`), and the event adapter (`mastra-event-adapter.ts`)
24
+ * can never drift on the metadata key.
25
+ */
26
+ export const TOOL_ARGS_VALIDATION_ERROR_METADATA = {
27
+ salesforce: { toolArgsValidationError: true },
28
+ };
29
+ /** True iff `providerMetadata` carries the {@link TOOL_ARGS_VALIDATION_ERROR_METADATA} marker. */
30
+ export function hasToolArgsValidationErrorMarker(providerMetadata) {
31
+ if (typeof providerMetadata !== 'object' || providerMetadata === null)
32
+ return false;
33
+ const salesforce = providerMetadata['salesforce'];
34
+ if (typeof salesforce !== 'object' || salesforce === null)
35
+ return false;
36
+ return salesforce['toolArgsValidationError'] === true;
37
+ }
38
+ //# sourceMappingURL=tool-args-marker.js.map
@@ -1,4 +1,4 @@
1
- import type { LanguageModelV3FinishReason, LanguageModelV3StreamPart, LanguageModelV3ToolResultOutput, LanguageModelV3Usage } from '@ai-sdk/provider';
1
+ import type { LanguageModelV3CallOptions, LanguageModelV3FinishReason, LanguageModelV3StreamPart, LanguageModelV3ToolCall, LanguageModelV3ToolResult, LanguageModelV3ToolResultOutput, LanguageModelV3Usage } from '@ai-sdk/provider';
2
2
  import { type Clock, type LogBus, type UniqueIDGenerator } from '@salesforce/agentic-common';
3
3
  import type { Model } from '@salesforce/sfdx-agent-sdk';
4
4
  /**
@@ -288,33 +288,73 @@ type WireCommunicationLikeEvent = {
288
288
  */
289
289
  export declare function toolResultOutputToString(output: LanguageModelV3ToolResultOutput): string;
290
290
  /**
291
- * Normalize a streamed/returned tool-call arguments STRING to a
292
- * schema-conforming stringified JSON object.
293
- *
294
- * A no-argument tool (input schema `{ type:'object', properties:{} }`) accrues
295
- * no argument fragments on the wire — Anthropic streams no `input_json_delta`,
296
- * the OpenAI Responses/Chat-Completions APIs send no arg deltas — so the
297
- * accumulated buffer is the empty string `''`. `''` is NOT a schema-conforming
298
- * stringified JSON object: a strict consumer (Mastra `secureJsonParse`, any
299
- * `JSON.parse`) throws "the provided JSON could not be parsed" and the model
300
- * retries, producing the empty-args retry loop (W-23789221; same class as the
301
- * closed gateway-processor defect W-22696051). `'{}'` is the only valid no-arg
302
- * value.
291
+ * Provider-options namespace + keys the harness uses to carry the per-turn
292
+ * tool-args retry-counter scope down to a pass-through's `doStream` /
293
+ * `doGenerate` (S1, W-24023028). The Mastra `LanguageModel` is built once per
294
+ * agent (agent-lifetime), and each `doStream` is one internal model STEP — not
295
+ * a turn — so the pass-through cannot know the turn/thread on its own. The
296
+ * harness stamps the agent id + thread id onto `agent.stream({ providerOptions })`;
297
+ * AI-SDK forwards `providerOptions` to `LanguageModelV3CallOptions.providerOptions`
298
+ * UNCHANGED across every step of that one streamed run (verified empirically), so
299
+ * the pass-through reads them here to resolve a per-turn, per-thread counter
300
+ * instead of an agent-lifetime one. They ride as TWO discrete fields (not one
301
+ * concatenated string) so a consumer id containing the delimiter can't alias two
302
+ * distinct `(agentId, threadId)` scopes. The `salesforce` namespace is our
303
+ * pass-through's own provider slot; it is never forwarded onto the upstream
304
+ * request body (the pass-through builds the wire params from `options.prompt` /
305
+ * `tools`, not `providerOptions`).
306
+ */
307
+ export declare const TOOL_ARGS_RETRY_SCOPE_PROVIDER = "salesforce";
308
+ export declare const TOOL_ARGS_RETRY_SCOPE_AGENT_ID = "toolArgsRetryAgentId";
309
+ export declare const TOOL_ARGS_RETRY_SCOPE_THREAD_ID = "toolArgsRetryThreadId";
310
+ /** The per-turn retry-counter scope (agent + thread) stamped by the harness, if present. */
311
+ export declare function readToolArgsRetryScope(options: LanguageModelV3CallOptions): {
312
+ agentId: string;
313
+ threadId: string;
314
+ } | undefined;
315
+ /**
316
+ * Builds the `LanguageModelV3StreamPart`(s) for one tool-call emission site
317
+ * (W-24023028), replacing the bare `normalizeToolArgsString` seam.
303
318
  *
304
- * Empty (including whitespace-only) → `'{}'`. Every non-empty buffer — even a
305
- * malformed partial like `'{"q":'` — is returned VERBATIM: we never re-parse or
306
- * re-stringify real content, so a genuinely bad payload still reaches the
307
- * consumer and is still rejected. Applied at every provider's tool-call
308
- * emission seam so the rule lives in one auditable place.
319
+ * - Not a consumer/frontend tool (MCP, built-in) → returns the single
320
+ * `tool-call` part, byte-for-byte the same shape `normalizeToolArgsString`
321
+ * produced (AC7 — these tool types already validate by executing and
322
+ * returning a per-tool `isError`, so this path is untouched).
323
+ * - A consumer/frontend tool (an `AgentConfig.tools` entry with no `execute` —
324
+ * the harness parks the call and forwards args to the SDK consumer) → the
325
+ * raw `argsBuffer` is validated via `validateToolArgs` BEFORE Mastra's own
326
+ * tool pipeline (`safeParseToolArgs`) can mask a malformed buffer into
327
+ * `{ value: '<raw>' }`.
328
+ * - `'ok'` → the normal `tool-call` part, using the *validated* (re-stringified)
329
+ * args rather than the raw buffer.
330
+ * - `'unparseable'` / `'schema'` → the malformed/schema-violating buffer must
331
+ * never reach the SDK consumer. Returns the `tool-call` part (so the
332
+ * model's own turn history stays coherent) immediately followed by a
333
+ * synthetic `tool-result` part carrying `isError: true` and the message
334
+ * from `describeToolArgsFailure`. The `tool-call` part carries
335
+ * `providerExecuted: true` — Mastra's own orchestration
336
+ * (`inferProviderExecuted`) treats a provider-executed tool call as
337
+ * already resolved and never invokes the consumer-tool bridge's
338
+ * `execute()` for it, so the bad buffer never parks waiting on
339
+ * `ChatSession.submitToolResult`. (`LanguageModelV3ToolResult` has no
340
+ * `providerExecuted` field, so the paired `tool-result` doesn't carry one.)
309
341
  *
310
- * Sibling to `sfdx-agent-chat-generations`'s `normalizeToolArguments` (the
311
- * gpt5-processor path). This one additionally coerces a whitespace-only buffer
312
- * — the pass-through streaming paths accumulate raw wire fragments that could in
313
- * principle be whitespace, whereas the chat-generations processor normalizes
314
- * earlier. The packages share no dependency, so the rule is duplicated rather
315
- * than imported; keep the two in sync if the empty-definition changes.
342
+ * `opts.retryCounts`, when supplied, is a per-tool-name consecutive-failure
343
+ * counter this function owns: incremented on `'unparseable'`/`'schema'`, reset
344
+ * on `'ok'`. Callers own the Map's lifetime (one per `LanguageModelV3`
345
+ * instance here — see each pass-through builder) — keyed by tool NAME because
346
+ * every model retry mints a brand-new `toolCallId`, so there is no stable id to
347
+ * key a per-attempt counter across attempts. Known, accepted tradeoff: two
348
+ * independent calls to the same tool name share a counter — cosmetic only
349
+ * (it only affects whether the "will not be retried further" clause appears),
350
+ * never a correctness issue, since a failure is always a normal isError
351
+ * tool-result and the turn is never terminated.
316
352
  */
317
- export declare function normalizeToolArgsString(args: string | null | undefined): string;
353
+ export declare function buildValidatedToolCallChunk(toolCallId: string, toolName: string, argsBuffer: string | null | undefined, schema: Record<string, unknown> | undefined, isConsumerTool: boolean, opts?: {
354
+ retryCounts?: Map<string, number>;
355
+ maxAttempts?: number;
356
+ logBus?: LogBus;
357
+ }): Array<LanguageModelV3ToolCall | LanguageModelV3ToolResult>;
318
358
  /**
319
359
  * Predicate the missing-usage faithfulness invariant (W-22692131) enforces at
320
360
  * every terminal `finish` site: when every input/output token field on the
@@ -4,6 +4,8 @@
4
4
  */
5
5
  import { RealClock, UUIDGenerator } from '@salesforce/agentic-common';
6
6
  import { EventStreamCodec } from '@smithy/eventstream-codec';
7
+ import { describeToolArgsFailure, recordToolArgsFailure, validateToolArgs, TOOL_ARGS_MAX_ATTEMPTS, } from '@salesforce/sfdx-agent-sdk/harness';
8
+ import { TOOL_ARGS_VALIDATION_ERROR_METADATA } from './tool-args-marker.js';
7
9
  import { IdleReadTimeoutError } from './stream-errors.js';
8
10
  /**
9
11
  * Shared V3 helpers for the Mastra harness's `LanguageModelV3` implementations
@@ -736,8 +738,7 @@ export function toolResultOutputToString(output) {
736
738
  }
737
739
  }
738
740
  /**
739
- * Normalize a streamed/returned tool-call arguments STRING to a
740
- * schema-conforming stringified JSON object.
741
+ * Coerces an empty/whitespace-only tool-call argument buffer to `'{}'`.
741
742
  *
742
743
  * A no-argument tool (input schema `{ type:'object', properties:{} }`) accrues
743
744
  * no argument fragments on the wire — Anthropic streams no `input_json_delta`,
@@ -749,11 +750,11 @@ export function toolResultOutputToString(output) {
749
750
  * closed gateway-processor defect W-22696051). `'{}'` is the only valid no-arg
750
751
  * value.
751
752
  *
752
- * Empty (including whitespace-only) → `'{}'`. Every non-empty buffer — even a
753
- * malformed partial like `'{"q":'` — is returned VERBATIM: we never re-parse or
754
- * re-stringify real content, so a genuinely bad payload still reaches the
755
- * consumer and is still rejected. Applied at every provider's tool-call
756
- * emission seam so the rule lives in one auditable place.
753
+ * Used only for the non-consumer-tool (MCP / built-in) passthrough in
754
+ * {@link buildValidatedToolCallChunk} — those tool types are explicitly out of
755
+ * scope for arg validation (W-24023028 AC7) and must see byte-for-byte
756
+ * unchanged behavior, so a non-empty buffer — even a malformed partial like
757
+ * `'{"q":'` — is still returned VERBATIM here.
757
758
  *
758
759
  * Sibling to `sfdx-agent-chat-generations`'s `normalizeToolArguments` (the
759
760
  * gpt5-processor path). This one additionally coerces a whitespace-only buffer
@@ -762,9 +763,151 @@ export function toolResultOutputToString(output) {
762
763
  * earlier. The packages share no dependency, so the rule is duplicated rather
763
764
  * than imported; keep the two in sync if the empty-definition changes.
764
765
  */
765
- export function normalizeToolArgsString(args) {
766
+ function coerceEmptyArgsBuffer(args) {
766
767
  return args && args.trim() !== '' ? args : '{}';
767
768
  }
769
+ /**
770
+ * Provider-options namespace + keys the harness uses to carry the per-turn
771
+ * tool-args retry-counter scope down to a pass-through's `doStream` /
772
+ * `doGenerate` (S1, W-24023028). The Mastra `LanguageModel` is built once per
773
+ * agent (agent-lifetime), and each `doStream` is one internal model STEP — not
774
+ * a turn — so the pass-through cannot know the turn/thread on its own. The
775
+ * harness stamps the agent id + thread id onto `agent.stream({ providerOptions })`;
776
+ * AI-SDK forwards `providerOptions` to `LanguageModelV3CallOptions.providerOptions`
777
+ * UNCHANGED across every step of that one streamed run (verified empirically), so
778
+ * the pass-through reads them here to resolve a per-turn, per-thread counter
779
+ * instead of an agent-lifetime one. They ride as TWO discrete fields (not one
780
+ * concatenated string) so a consumer id containing the delimiter can't alias two
781
+ * distinct `(agentId, threadId)` scopes. The `salesforce` namespace is our
782
+ * pass-through's own provider slot; it is never forwarded onto the upstream
783
+ * request body (the pass-through builds the wire params from `options.prompt` /
784
+ * `tools`, not `providerOptions`).
785
+ */
786
+ export const TOOL_ARGS_RETRY_SCOPE_PROVIDER = 'salesforce';
787
+ export const TOOL_ARGS_RETRY_SCOPE_AGENT_ID = 'toolArgsRetryAgentId';
788
+ export const TOOL_ARGS_RETRY_SCOPE_THREAD_ID = 'toolArgsRetryThreadId';
789
+ /** The per-turn retry-counter scope (agent + thread) stamped by the harness, if present. */
790
+ export function readToolArgsRetryScope(options) {
791
+ const scope = options.providerOptions?.[TOOL_ARGS_RETRY_SCOPE_PROVIDER];
792
+ const agentId = scope?.[TOOL_ARGS_RETRY_SCOPE_AGENT_ID];
793
+ const threadId = scope?.[TOOL_ARGS_RETRY_SCOPE_THREAD_ID];
794
+ return typeof agentId === 'string' && typeof threadId === 'string' ? { agentId, threadId } : undefined;
795
+ }
796
+ /**
797
+ * Builds the `LanguageModelV3StreamPart`(s) for one tool-call emission site
798
+ * (W-24023028), replacing the bare `normalizeToolArgsString` seam.
799
+ *
800
+ * - Not a consumer/frontend tool (MCP, built-in) → returns the single
801
+ * `tool-call` part, byte-for-byte the same shape `normalizeToolArgsString`
802
+ * produced (AC7 — these tool types already validate by executing and
803
+ * returning a per-tool `isError`, so this path is untouched).
804
+ * - A consumer/frontend tool (an `AgentConfig.tools` entry with no `execute` —
805
+ * the harness parks the call and forwards args to the SDK consumer) → the
806
+ * raw `argsBuffer` is validated via `validateToolArgs` BEFORE Mastra's own
807
+ * tool pipeline (`safeParseToolArgs`) can mask a malformed buffer into
808
+ * `{ value: '<raw>' }`.
809
+ * - `'ok'` → the normal `tool-call` part, using the *validated* (re-stringified)
810
+ * args rather than the raw buffer.
811
+ * - `'unparseable'` / `'schema'` → the malformed/schema-violating buffer must
812
+ * never reach the SDK consumer. Returns the `tool-call` part (so the
813
+ * model's own turn history stays coherent) immediately followed by a
814
+ * synthetic `tool-result` part carrying `isError: true` and the message
815
+ * from `describeToolArgsFailure`. The `tool-call` part carries
816
+ * `providerExecuted: true` — Mastra's own orchestration
817
+ * (`inferProviderExecuted`) treats a provider-executed tool call as
818
+ * already resolved and never invokes the consumer-tool bridge's
819
+ * `execute()` for it, so the bad buffer never parks waiting on
820
+ * `ChatSession.submitToolResult`. (`LanguageModelV3ToolResult` has no
821
+ * `providerExecuted` field, so the paired `tool-result` doesn't carry one.)
822
+ *
823
+ * `opts.retryCounts`, when supplied, is a per-tool-name consecutive-failure
824
+ * counter this function owns: incremented on `'unparseable'`/`'schema'`, reset
825
+ * on `'ok'`. Callers own the Map's lifetime (one per `LanguageModelV3`
826
+ * instance here — see each pass-through builder) — keyed by tool NAME because
827
+ * every model retry mints a brand-new `toolCallId`, so there is no stable id to
828
+ * key a per-attempt counter across attempts. Known, accepted tradeoff: two
829
+ * independent calls to the same tool name share a counter — cosmetic only
830
+ * (it only affects whether the "will not be retried further" clause appears),
831
+ * never a correctness issue, since a failure is always a normal isError
832
+ * tool-result and the turn is never terminated.
833
+ */
834
+ export function buildValidatedToolCallChunk(toolCallId, toolName, argsBuffer, schema, isConsumerTool, opts) {
835
+ if (!isConsumerTool) {
836
+ return [{ type: 'tool-call', toolCallId, toolName, input: coerceEmptyArgsBuffer(argsBuffer) }];
837
+ }
838
+ const result = validateToolArgs(argsBuffer, schema);
839
+ if (result.outcome === 'ok') {
840
+ opts?.retryCounts?.delete(toolName);
841
+ opts?.logBus?.debug('Tool arg validation: ok', { toolName });
842
+ return [{ type: 'tool-call', toolCallId, toolName, input: JSON.stringify(result.args) }];
843
+ }
844
+ if (result.outcome === 'invalid-schema') {
845
+ // B1 fail-open: the consumer's declared `inputSchema` did not compile,
846
+ // so this tool degrades to no-validation rather than throwing mid-stream.
847
+ // Emit the ordinary (NON-provider-executed) `tool-call` part exactly as
848
+ // the `'ok'` path does, so Mastra's own pipeline invokes the consumer-tool
849
+ // bridge with the parsed args — the model is never told to fix a schema it
850
+ // didn't author. Log the misconfiguration for the operator.
851
+ opts?.retryCounts?.delete(toolName);
852
+ opts?.logBus?.warn('Tool arg validation: schema did not compile, passing args through', {
853
+ toolName,
854
+ schemaError: result.schemaError,
855
+ });
856
+ return [{ type: 'tool-call', toolCallId, toolName, input: JSON.stringify(result.args) }];
857
+ }
858
+ const maxAttempts = opts?.maxAttempts ?? TOOL_ARGS_MAX_ATTEMPTS;
859
+ // A standalone / consumer-injected builder may thread no `retryCounts`
860
+ // resolver; a throwaway map then yields attempt 1 every call (no
861
+ // accumulation), matching the pre-helper `?.` fallback behavior.
862
+ const { attempt, exhausted } = recordToolArgsFailure(opts?.retryCounts ?? new Map(), toolName, maxAttempts);
863
+ // Per-outcome detail on the failure warn — schema violation paths, or the
864
+ // unparseable reason (`not-json` vs `not-object`) — so an operator can tell a
865
+ // syntax failure from a wrong-shape one, carried on the exhausted warn too (N2).
866
+ const failureDetail = result.outcome === 'schema'
867
+ ? { violationPaths: result.violations.map((v) => v.path) }
868
+ : { reason: result.reason };
869
+ opts?.logBus?.warn(exhausted
870
+ ? 'Tool arg validation: retries exhausted, final error'
871
+ : `Tool arg validation: fed back (${result.outcome})`, {
872
+ toolName,
873
+ attempt,
874
+ maxAttempts,
875
+ ...failureDetail,
876
+ });
877
+ const message = describeToolArgsFailure(toolName, result, exhausted ? { exhausted: true, maxAttempts } : undefined);
878
+ return [
879
+ {
880
+ type: 'tool-call',
881
+ toolCallId,
882
+ toolName,
883
+ input: coerceEmptyArgsBuffer(argsBuffer),
884
+ // `providerExecuted: true` stops Mastra's `inferProviderExecuted` from ever
885
+ // invoking the consumer-tool bridge's `execute()` for this call, so the bad
886
+ // buffer never parks waiting on `submitToolResult`. The `tool-call` ChatEvent
887
+ // IS surfaced to the consumer (W-24023028) — a gated call is observable on the
888
+ // live stream just like an MCP/built-in tool whose executor rejected bad args
889
+ // — but the consumer's own tool HANDLER is never invoked for it, and it should
890
+ // key its rendering off the paired error `tool-result` below.
891
+ providerExecuted: true,
892
+ },
893
+ {
894
+ type: 'tool-result',
895
+ toolCallId,
896
+ toolName,
897
+ result: message,
898
+ isError: true,
899
+ // Mastra's own chunk-collection pipeline (`buildMessagesFromChunks`, bundled in
900
+ // @mastra/core) drops the `isError` flag on a provider-executed tool-result chunk
901
+ // when persisting the run's assistant message — it always writes `state: 'result'`
902
+ // regardless of `isError`. `providerMetadata` is one of the few fields that pipeline
903
+ // DOES carry through onto the persisted `tool-invocation` part (see
904
+ // `mapMastraPartToMessagePart` in `mastra-message-mapper.ts`), and `MastraEventAdapter`
905
+ // ORs it into the LIVE `tool-result` event's `isError` too — so the error signal
906
+ // survives on both the live stream and the persisted read path.
907
+ providerMetadata: TOOL_ARGS_VALIDATION_ERROR_METADATA,
908
+ },
909
+ ];
910
+ }
768
911
  /**
769
912
  * Predicate the missing-usage faithfulness invariant (W-22692131) enforces at
770
913
  * every terminal `finish` site: when every input/output token field on the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/sfdx-agent-harness-mastra",
3
- "version": "0.71.0",
3
+ "version": "0.73.0",
4
4
  "description": "Mastra-backed AgentHarness implementation for @salesforce/sfdx-agent-sdk",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -31,23 +31,23 @@
31
31
  "dependencies": {
32
32
  "@ai-sdk/provider": "^4.0.10",
33
33
  "@anthropic-ai/bedrock-sdk": "^0.32.0",
34
- "@anthropic-ai/sdk": "^0.115.0",
35
- "@mastra/core": "^1.62.0",
36
- "@mastra/libsql": "^1.18.0",
34
+ "@anthropic-ai/sdk": "^0.122.0",
35
+ "@mastra/core": "^1.63.2",
36
+ "@mastra/libsql": "^1.22.2",
37
37
  "@mastra/mcp": "^1.17.2",
38
- "@mastra/memory": "^1.24.0",
38
+ "@mastra/memory": "^1.28.1",
39
39
  "@mastra/schema-compat": "^1.3.7",
40
40
  "@salesforce/agentic-common": "0.19.0",
41
41
  "@smithy/eventstream-codec": "^4.5.2",
42
42
  "openai": "^7.0.0"
43
43
  },
44
44
  "peerDependencies": {
45
- "@salesforce/sfdx-agent-sdk": "0.72.0"
45
+ "@salesforce/sfdx-agent-sdk": "0.74.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.72.0",
50
+ "@salesforce/sfdx-agent-sdk": "0.74.0",
51
51
  "@types/node": "^22.20.1",
52
52
  "@vitest/coverage-istanbul": "^4.1.11",
53
53
  "@vitest/eslint-plugin": "^1.6.27",
@@ -57,12 +57,12 @@
57
57
  "eslint-plugin-import": "^2.32.0",
58
58
  "eslint-plugin-n": "^18.3.0",
59
59
  "globals": "^17.12.0",
60
- "lint-staged": "^17.3.0",
60
+ "lint-staged": "^17.4.1",
61
61
  "prettier": "^3.9.6",
62
62
  "rimraf": "^6.1.3",
63
- "tsx": "^4.23.12",
63
+ "tsx": "^4.23.13",
64
64
  "typescript": "^7.0.2",
65
- "typescript-eslint": "^8.68.0",
65
+ "typescript-eslint": "^8.69.0",
66
66
  "vitest": "^4.1.11"
67
67
  },
68
68
  "engines": {