@salesforce/sfdx-agent-harness-mastra 0.71.0 → 0.72.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/dist/mastra-event-adapter.d.ts +1 -1
- package/dist/mastra-event-adapter.js +12 -2
- package/dist/mastra-harness.d.ts +33 -0
- package/dist/mastra-harness.js +110 -6
- package/dist/mastra-message-mapper.js +14 -1
- package/dist/mastra-tool-mapper.js +22 -1
- package/dist/pass-through-anthropic.d.ts +44 -0
- package/dist/pass-through-anthropic.js +22 -20
- package/dist/pass-through-openai-chat-completions.d.ts +6 -0
- package/dist/pass-through-openai-chat-completions.js +24 -20
- package/dist/pass-through-openai.d.ts +6 -0
- package/dist/pass-through-openai.js +24 -20
- package/dist/tool-args-marker.d.ts +28 -0
- package/dist/tool-args-marker.js +38 -0
- package/dist/v3-stream-mapping.d.ts +65 -25
- package/dist/v3-stream-mapping.js +151 -8
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
All notable changes to `@salesforce/sfdx-agent-harness-mastra` are documented in this file.
|
|
4
4
|
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
5
5
|
|
|
6
|
+
## [0.72.0] - 2026-09-04
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
- **agent-sdk,harness-claude,harness-mastra,harness-openai**: validate frontend tool-call args @W-24023028@ ([#789](https://github.com/forcedotcom/agentic-dx/pull/789))
|
|
10
|
+
|
|
6
11
|
## [0.71.0] - 2026-09-02
|
|
7
12
|
|
|
8
13
|
_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
|
|
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
|
|
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
|
-
|
|
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));
|
package/dist/mastra-harness.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/mastra-harness.js
CHANGED
|
@@ -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
|
-
|
|
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({
|
|
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({
|
|
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({
|
|
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
|
-
{
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
193
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
* the
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
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
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
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
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
753
|
-
*
|
|
754
|
-
*
|
|
755
|
-
*
|
|
756
|
-
*
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.72.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.
|
|
45
|
+
"@salesforce/sfdx-agent-sdk": "0.73.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.
|
|
50
|
+
"@salesforce/sfdx-agent-sdk": "0.73.0",
|
|
51
51
|
"@types/node": "^22.20.1",
|
|
52
52
|
"@vitest/coverage-istanbul": "^4.1.11",
|
|
53
53
|
"@vitest/eslint-plugin": "^1.6.27",
|